[CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[a-z0-9_.-]{1,128}$')] [string]$CaseCode, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')] [string]$CommandName, [Parameter(Mandatory = $true)][string]$CommandInputFile, [Parameter(Mandatory = $true)][string]$OutputPath, [Parameter(Mandatory = $true)][string]$VerifierCliPath, [Parameter(Mandatory = $true)][string]$RuntimeCliPath, [Parameter(Mandatory = $true)][string]$UatAuthorizationFile, [Parameter(Mandatory = $true)][string]$UatTokenVaultPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedUatAuthorizationSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedVerifierCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')] [string]$ExpectedRuntimeCliVersion, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedRuntimeCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedVerifierSignerThumbprint, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedRuntimeSignerThumbprint, [Parameter(Mandatory = $true)] [ValidateRange(1, 2147483647)] [int]$ErpProcessId, [switch]$Execute, [Security.SecureString]$IdempotencyKey, [string]$IdempotencyKeyFile = '', [switch]$PauseAfterPlanForOperatorStaging, [switch]$NonInteractive, [ValidateRange(-1, 1000)] [int]$BusinessMutationCount = -1, [Nullable[bool]]$NativeConfirmationObserved = $null, [ValidateRange(-1, 1000)] [int]$AuditEventCount = -1, [Nullable[bool]]$SourceDocumentWritePayloadBound = $null, [ValidateRange(-1, 1000)] [int]$SourceDocumentAuditCount = -1, [string]$CorrelatedAuditOutputPath = '', [string]$RestrictedWorkingRoot = '', [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 'workflow_write_case_capture_failed:windows_powershell_51_required' } $windowsIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() $windowsPrincipal = [Security.Principal.WindowsPrincipal]::new($windowsIdentity) if (-not $windowsPrincipal.IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'workflow_write_case_capture_failed:elevated_operator_required' } $utf8 = [Text.UTF8Encoding]::new($false, $true) $maximumResponseCharacters = 4 * 1024 * 1024 $rawDirectory = $null $idempotencyPlain = $null $uatToken = $null $uatAuditTokenSha256 = $null $verifierCliLock = $null $runtimeCliLock = $null $uatAuthorizationLock = $null $uatTokenVaultLock = $null $summaryJson = $null $stagedOutputs = New-Object System.Collections.Generic.List[string] $publishedOutputs = New-Object System.Collections.Generic.List[string] function Throw-CaptureError([string]$Code) { throw ("workflow_write_case_capture_failed:" + $Code) } function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) { $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-CaptureError $Code } $current = $current.Parent } } function Resolve-RegularFile( [string]$Path, [long]$MaximumBytes, [string]$Code ) { try { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { Throw-CaptureError $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-CaptureError $Code } Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code return $full } catch { if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError $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-CaptureError $Code } $directory = [IO.Path]::GetDirectoryName($full) Assert-NoReparseDirectoryChain $directory $Code return $full } catch { if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError $Code } } function New-RestrictedDirectory([string]$Root) { $path = $null try { $fullRoot = [IO.Path]::GetFullPath($Root) [IO.Directory]::CreateDirectory($fullRoot) | Out-Null Assert-NoReparseDirectoryChain $fullRoot 'restricted_working_root_invalid' $path = Join-Path $fullRoot ('case-' + [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-CaptureError '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('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError '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 Get-Sha256Hex([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString( $sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Assert-RestrictedVaultAcl([string]$Path) { try { $sections = [Security.AccessControl.AccessControlSections]::All $acl = [IO.File]::GetAccessControl($Path, $sections) $owner = $acl.GetOwner( [Security.Principal.SecurityIdentifier]).Value $currentSid = $windowsIdentity.User.Value $systemSid = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null).Value if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid) { Throw-CaptureError 'uat_token_vault_acl_invalid' } $rules = @($acl.GetAccessRules( $true, $true, [Security.Principal.SecurityIdentifier])) if ($rules.Count -ne 2) { Throw-CaptureError 'uat_token_vault_acl_invalid' } $seen = @{} foreach ($rule in $rules) { $sid = [string]$rule.IdentityReference.Value if ($rule.IsInherited -or $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or ($sid -cne $currentSid -and $sid -cne $systemSid) -or (($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl) -or $seen.ContainsKey($sid)) { Throw-CaptureError 'uat_token_vault_acl_invalid' } $seen[$sid] = $true } if (-not $seen.ContainsKey($currentSid) -or -not $seen.ContainsKey($systemSid)) { Throw-CaptureError 'uat_token_vault_acl_invalid' } $sddl = $acl.GetSecurityDescriptorSddlForm($sections) if ($sddl -cnotmatch 'S:.*\(ML;;NW;;;HI\)') { Throw-CaptureError 'uat_token_vault_integrity_invalid' } } catch { if ($_.Exception.Message.StartsWith( 'workflow_write_case_capture_failed:')) { throw } Throw-CaptureError 'uat_token_vault_acl_invalid' } } function Get-PreparationCommand( [string]$CaptureCaseCode, [string]$TargetCommandName ) { if ($TargetCommandName -ceq 'purchase.invoice.create' -and $CaptureCaseCode -cne 'purchase_permission_denied') { return 'purchase.invoice.resolve' } if ($TargetCommandName -ceq 'hr.leave.create' -and $CaptureCaseCode -cnotin @( 'leave_resolution_proof_bypass_blocked', 'leave_local_time_zone_rejected', 'leave_permission_denied')) { return 'hr.leave.resolve' } return $null } function Read-VerifiedUatGrant( [object]$VerifiedData, [string]$AuthorizationPath, [string]$VaultPath, [string]$CaptureCaseCode, [string]$TargetCommandName ) { try { if ($null -eq $VerifiedData -or [string]$VerifiedData.packageType -cne 'workflow_write_uat_authorization' -or [string]$VerifiedData.schemaVersion -cne '1.2' -or [string]$VerifiedData.sourceSha256 -cne $ExpectedUatAuthorizationSha256.ToLowerInvariant() -or [string]$VerifiedData.verifierCli.fileName -cne 'lserp-cli.exe' -or [string]$VerifiedData.verifierCli.sha256 -cne $expectedVerifierCliHash -or [string]$VerifiedData.verifierCli.signerThumbprint -cne $expectedVerifierCliSigner -or $VerifiedData.verifierCli.requiresElevation -ne $true -or [string]$VerifiedData.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or [string]$VerifiedData.runtimeCli.version -cne $ExpectedRuntimeCliVersion -or [string]$VerifiedData.runtimeCli.sha256 -cne $expectedRuntimeCliHash -or [string]$VerifiedData.runtimeCli.signerThumbprint -cne $expectedRuntimeCliSigner -or $VerifiedData.runtimeCli.requiresElevation -ne $false -or $VerifiedData.runtimeCli.bridgeOnly -ne $true -or $VerifiedData.runtimeCli.databaseDirectAccess -ne $false -or [string]$VerifiedData.runtimeCli.sessionSource -cne 'current_logged_in_erp_process' -or $VerifiedData.signatureVerified -ne $true -or $VerifiedData.uatAuthorized -ne $true -or $VerifiedData.productionReady -ne $false) { Throw-CaptureError 'uat_authorization_verification_invalid' } $authorizationText = [IO.File]::ReadAllText($AuthorizationPath, $utf8) $authorization = $authorizationText | ConvertFrom-Json $issuedAtMatches = [Text.RegularExpressions.Regex]::Matches( $authorizationText, '"issuedAtUtc"\s*:\s*"(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"', [Text.RegularExpressions.RegexOptions]::CultureInvariant) $expiresAtMatches = [Text.RegularExpressions.Regex]::Matches( $authorizationText, '"expiresAtUtc"\s*:\s*"(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"', [Text.RegularExpressions.RegexOptions]::CultureInvariant) if (-not (Test-ExactProperties $authorization @( 'schemaVersion', 'contentSha256', 'signatureAlgorithm', 'certificateThumbprint', 'signatureBase64', 'content')) -or [string]$authorization.schemaVersion -cne '1.2' -or $null -eq $authorization.content -or -not (Test-ExactProperties $authorization.content.erpScope @( 'accountBook', 'subSystemId', 'userId', 'userName', 'databaseScopeFingerprint')) -or [string]$authorization.content.erpScope.databaseScopeFingerprint -cne [string]$VerifiedData.erpScope.databaseScopeFingerprint -or [string]$authorization.content.authorizationId -cne [string]$VerifiedData.authorizationId -or $issuedAtMatches.Count -ne 1 -or $expiresAtMatches.Count -ne 1) { Throw-CaptureError 'uat_authorization_contract_invalid' } $signedScope = $authorization.content.erpScope foreach ($scopeField in @( 'accountBook', 'subSystemId', 'userId', 'userName')) { $scopeValue = [string]$signedScope.$scopeField if ([string]::IsNullOrWhiteSpace($scopeValue) -or $scopeValue.Length -gt 256 -or $scopeValue -cne $scopeValue.Trim()) { Throw-CaptureError 'uat_authorization_scope_invalid' } foreach ($character in $scopeValue.ToCharArray()) { if ([char]::IsControl($character)) { Throw-CaptureError 'uat_authorization_scope_invalid' } } } if ((Get-Sha256Hex ($utf8.GetBytes( [string]$signedScope.accountBook))) -cne [string]$VerifiedData.erpScope.accountBookSha256 -or (Get-Sha256Hex ($utf8.GetBytes( [string]$signedScope.subSystemId))) -cne [string]$VerifiedData.erpScope.subSystemIdSha256 -or (Get-Sha256Hex ($utf8.GetBytes( [string]$signedScope.userId))) -cne [string]$VerifiedData.erpScope.userIdSha256 -or (Get-Sha256Hex ($utf8.GetBytes( [string]$signedScope.userName))) -cne [string]$VerifiedData.erpScope.userNameSha256) { Throw-CaptureError 'uat_authorization_scope_invalid' } $workflowName = if ($CaptureCaseCode.StartsWith( 'purchase_', [StringComparison]::Ordinal)) { 'purchase' } elseif ($CaptureCaseCode.StartsWith( 'leave_', [StringComparison]::Ordinal)) { 'leave' } else { Throw-CaptureError 'uat_case_not_authorized' } $allCases = New-Object System.Collections.Generic.List[object] foreach ($workflow in @($authorization.content.workflows)) { foreach ($case in @($workflow.cases)) { $allCases.Add([pscustomobject]@{ Workflow = [string]$workflow.workflow Case = $case }) } } $matches = @($allCases | Where-Object { $_.Workflow -ceq $workflowName -and [string]$_.Case.caseCode -ceq $CaptureCaseCode }) if ($matches.Count -ne 1) { Throw-CaptureError 'uat_case_not_authorized' } $caseAuthorization = $matches[0].Case if (-not (Test-ExactProperties $caseAuthorization @( 'caseCode', 'expectedCommandName', 'allowedCommands', 'tokenSha256')) -or [string]$caseAuthorization.expectedCommandName -cne $TargetCommandName -or [string]$caseAuthorization.tokenSha256 -cnotmatch '^[a-f0-9]{64}$') { Throw-CaptureError 'uat_case_command_mismatch' } $allowedCommands = @($caseAuthorization.allowedCommands | ForEach-Object { [string]$_ }) $preparationCommand = Get-PreparationCommand ` $CaptureCaseCode $TargetCommandName if ($allowedCommands -cnotcontains $TargetCommandName -or ($null -ne $preparationCommand -and $allowedCommands -cnotcontains $preparationCommand)) { Throw-CaptureError 'uat_case_command_mismatch' } Assert-RestrictedVaultAcl $VaultPath $vault = [IO.File]::ReadAllText($VaultPath, $utf8) | ConvertFrom-Json if (-not (Test-ExactProperties $vault @( 'schemaVersion', 'authorizationId', 'protectedForUserSid', 'protectionScope', 'createdAtUtc', 'entries')) -or [string]$vault.schemaVersion -cne '1.0' -or [string]$vault.authorizationId -cne [string]$VerifiedData.authorizationId -or [string]$vault.protectedForUserSid -cne $windowsIdentity.User.Value -or [string]$vault.protectionScope -cne 'dpapi_current_user_high_integrity') { Throw-CaptureError 'uat_token_vault_contract_invalid' } $vaultEntries = @($vault.entries) if ($vaultEntries.Count -ne $allCases.Count) { Throw-CaptureError 'uat_token_vault_contract_invalid' } $entryKeys = @{} foreach ($entry in $vaultEntries) { if (-not (Test-ExactProperties $entry @( 'workflow', 'caseCode', 'protectedTokenBase64'))) { Throw-CaptureError 'uat_token_vault_contract_invalid' } $entryKey = [string]$entry.workflow + '|' + [string]$entry.caseCode if ($entryKeys.ContainsKey($entryKey)) { Throw-CaptureError 'uat_token_vault_contract_invalid' } $entryKeys[$entryKey] = $true } $tokenEntries = @($vaultEntries | Where-Object { [string]$_.workflow -ceq $workflowName -and [string]$_.caseCode -ceq $CaptureCaseCode }) if ($tokenEntries.Count -ne 1 -or [string]$tokenEntries[0].protectedTokenBase64 -cnotmatch '^[A-Za-z0-9+/]{64,4096}={0,2}$') { Throw-CaptureError 'uat_token_vault_entry_invalid' } $protectedBytes = [Convert]::FromBase64String( [string]$tokenEntries[0].protectedTokenBase64) $plainBytes = $null try { $entropy = $utf8.GetBytes([string]$VerifiedData.authorizationId) $plainBytes = [Security.Cryptography.ProtectedData]::Unprotect( $protectedBytes, $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser) $token = $utf8.GetString($plainBytes) } finally { if ($null -ne $plainBytes) { [Array]::Clear($plainBytes, 0, $plainBytes.Length) } } if ($token -cnotmatch '^[A-Za-z0-9_-]{32,128}$' -or (Get-Sha256Hex ($utf8.GetBytes($token))) -cne [string]$caseAuthorization.tokenSha256) { Throw-CaptureError 'uat_token_invalid' } return [pscustomobject]@{ AuthorizationId = [string]$VerifiedData.authorizationId AuthorizationIdSha256 = [string]$VerifiedData.authorizationIdSha256 AuthorizationSourceSha256 = [string]$VerifiedData.sourceSha256 AuthorizationContentSha256 = [string]$VerifiedData.contentSha256 AuthorizationIssuedAtUtc = $issuedAtMatches[0].Groups['value'].Value AuthorizationExpiresAtUtc = $expiresAtMatches[0].Groups['value'].Value CaseCode = $CaptureCaseCode Workflow = $workflowName TargetCommand = $TargetCommandName PreparationCommand = $preparationCommand AccountBook = [string]$signedScope.accountBook SubSystemId = [string]$signedScope.subSystemId UserId = [string]$signedScope.userId UserName = [string]$signedScope.userName DatabaseScopeFingerprint = ([string]$signedScope.databaseScopeFingerprint).ToLowerInvariant() TokenSha256 = [string]$caseAuthorization.tokenSha256 Token = $token } } catch { if ($_.Exception.Message.StartsWith( 'workflow_write_case_capture_failed:')) { throw } Throw-CaptureError 'uat_authorization_or_vault_invalid' } } function Read-CliEnvelope([string]$Text, [int]$ExitCode) { try { if ([string]::IsNullOrWhiteSpace($Text) -or $Text.Length -gt $maximumResponseCharacters) { Throw-CaptureError '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) -notmatch '^[A-Za-z0-9_.:-]{8,128}$') { Throw-CaptureError 'cli_response_invalid' } if ($document.ok) { if ($ExitCode -ne 0 -or -not (Test-ExactProperties $document @('ok', 'correlationId', 'data')) -or $null -eq $document.data) { Throw-CaptureError '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) -notmatch '^[a-z0-9_.-]{1,128}$' -or [int]$document.error.exitCode -ne $ExitCode) { Throw-CaptureError 'cli_response_invalid' } } return $document } catch { if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError 'cli_response_invalid' } } function Assert-TrustedVerifierCliUnchanged { $actual = (Get-FileHash -LiteralPath $script:verifierCliFull ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $script:expectedVerifierCliHash) { Throw-CaptureError 'verifier_cli_hash_changed' } } function Assert-TrustedRuntimeCliUnchanged { $actual = (Get-FileHash -LiteralPath $script:runtimeCliFull ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $script:expectedRuntimeCliHash) { Throw-CaptureError 'runtime_cli_hash_changed' } } function Assert-VerifierCliArguments([string[]]$Arguments) { $verifyAuthorization = $Arguments.Count -ge 4 -and $Arguments[0] -ceq 'acceptance' -and $Arguments[1] -ceq 'verify-uat-authorization' $projectObservation = $Arguments.Count -ge 6 -and $Arguments[0] -ceq 'adapters' -and $Arguments[1] -ceq 'project-write-observation-files' if (-not $verifyAuthorization -and -not $projectObservation) { Throw-CaptureError 'verifier_cli_command_denied' } } function Assert-RuntimeCliArguments([string[]]$Arguments) { $version = $Arguments.Count -eq 3 -and $Arguments[0] -ceq 'version' -and $Arguments[1] -ceq '--correlation-id' $bridge = $Arguments.Count -ge 2 -and $Arguments[0] -ceq 'bridge' -and $Arguments[1] -cin @('health', 'context', 'plan', 'execute') if (-not $version -and -not $bridge) { Throw-CaptureError 'runtime_cli_command_denied' } } function Invoke-TrustedCliProcess( [string]$ExecutablePath, [string]$ExpectedHash, [string[]]$Arguments, [string]$StandardInputValue, [string]$Step ) { $actualHash = (Get-FileHash -LiteralPath $ExecutablePath ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -cne $ExpectedHash) { Throw-CaptureError ($Step + '_cli_hash_changed') } $process = New-Object Diagnostics.Process try { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $ExecutablePath $start.WorkingDirectory = [IO.Path]::GetDirectoryName($ExecutablePath) $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-CaptureError ($Step + '_process_start_failed') } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if ($null -ne $StandardInputValue) { $process.StandardInput.Write($StandardInputValue) } $process.StandardInput.Close() $processTimeout = [Math]::Min(330000, $BridgeTimeoutMilliseconds + 30000) if (-not $process.WaitForExit($processTimeout)) { try { $process.Kill() } catch { } Throw-CaptureError ($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-CaptureError ($Step + '_stream_contract_invalid') } $raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr } $envelope = Read-CliEnvelope $raw $process.ExitCode $hashAfter = (Get-FileHash -LiteralPath $ExecutablePath ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($hashAfter -cne $ExpectedHash) { Throw-CaptureError ($Step + '_cli_hash_changed') } return [PSCustomObject]@{ ExitCode = $process.ExitCode Raw = $raw Envelope = $envelope } } catch { if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError ($Step + '_process_failed') } finally { if ($null -ne $process) { $process.Dispose() } } } function Invoke-TrustedVerifierCli( [string[]]$Arguments, [string]$StandardInputValue, [string]$Step ) { Assert-VerifierCliArguments $Arguments return Invoke-TrustedCliProcess ` $script:verifierCliFull ` $script:expectedVerifierCliHash ` $Arguments ` $StandardInputValue ` $Step } function Invoke-TrustedRuntimeCli( [string[]]$Arguments, [string]$StandardInputValue, [string]$Step ) { Assert-RuntimeCliArguments $Arguments return Invoke-TrustedCliProcess ` $script:runtimeCliFull ` $script:expectedRuntimeCliHash ` $Arguments ` $StandardInputValue ` $Step } function Read-StrictIdempotencyKeyFile([string]$Path) { $full = Resolve-RegularFile $Path 512 'idempotency_key_file_invalid' try { $value = [IO.File]::ReadAllText($full, $utf8) if ($value.EndsWith("`r`n", [StringComparison]::Ordinal)) { $value = $value.Substring(0, $value.Length - 2) } elseif ($value.EndsWith("`n", [StringComparison]::Ordinal)) { $value = $value.Substring(0, $value.Length - 1) } if ($value.Contains("`r") -or $value.Contains("`n")) { Throw-CaptureError 'idempotency_key_file_invalid' } return $value } catch { if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw } Throw-CaptureError 'idempotency_key_file_invalid' } } function Convert-SecureStringToPlainText([Security.SecureString]$Value) { $pointer = [IntPtr]::Zero try { $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value) return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { if ($pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } } function Resolve-IntegerObservation( [int]$Current, [int]$Minimum, [int]$Maximum, [string]$Prompt, [string]$Code ) { if ($Current -ge $Minimum -and $Current -le $Maximum) { return $Current } if ($NonInteractive) { Throw-CaptureError $Code } $raw = Read-Host $Prompt $parsed = 0 if (-not [int]::TryParse($raw, [ref]$parsed) -or $parsed -lt $Minimum -or $parsed -gt $Maximum) { Throw-CaptureError $Code } return $parsed } function Resolve-BooleanObservation( [Nullable[bool]]$Current, [string]$Prompt, [string]$Code ) { if ($null -ne $Current) { return [bool]$Current } if ($NonInteractive) { Throw-CaptureError $Code } $raw = (Read-Host ($Prompt + ' [true/false]')).Trim().ToLowerInvariant() if ($raw -eq 'true') { return $true } if ($raw -eq 'false') { return $false } Throw-CaptureError $Code } function Write-StrictJson([object]$Value, [string]$Path) { $json = $Value | ConvertTo-Json -Depth 20 [IO.File]::WriteAllText($Path, $json, $utf8) } function New-StagedOutput([string]$FinalPath) { $directory = [IO.Path]::GetDirectoryName($FinalPath) $path = Join-Path $directory ('capture-' + [Guid]::NewGuid().ToString('N') + '.json') if ([IO.File]::Exists($path)) { Throw-CaptureError 'staged_output_collision' } $stagedOutputs.Add($path) return $path } function Invoke-Projection([string]$IndexPath, [string]$StagePath, [string]$Step) { $result = Invoke-TrustedVerifierCli @( 'adapters', 'project-write-observation-files', '--input', $IndexPath, '--output', $StagePath, '--correlation-id', ('projection-' + [Guid]::NewGuid().ToString('N')) ) $null $Step if ($result.ExitCode -ne 0) { Throw-CaptureError ($Step + '_' + [string]$result.Envelope.error.code) } if (-not [IO.File]::Exists($StagePath)) { Throw-CaptureError ($Step + '_output_missing') } } try { $verifierCliFull = Resolve-RegularFile ` $VerifierCliPath 128MB 'verifier_cli_file_invalid' $runtimeCliFull = Resolve-RegularFile ` $RuntimeCliPath 128MB 'runtime_cli_file_invalid' if ([IO.Path]::GetFileName($verifierCliFull) -ine 'lserp-cli.exe') { Throw-CaptureError 'verifier_cli_filename_invalid' } if ([IO.Path]::GetFileName($runtimeCliFull) -ine 'lserp-agent-cli.exe') { Throw-CaptureError 'runtime_cli_filename_invalid' } if ($verifierCliFull -ieq $runtimeCliFull) { Throw-CaptureError 'cli_role_path_conflict' } $verifierCliLock = [IO.File]::Open( $verifierCliFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $runtimeCliLock = [IO.File]::Open( $runtimeCliFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $expectedVerifierCliHash = $ExpectedVerifierCliSha256.ToLowerInvariant() $expectedRuntimeCliHash = $ExpectedRuntimeCliSha256.ToLowerInvariant() $expectedVerifierCliSigner = $ExpectedVerifierSignerThumbprint.ToUpperInvariant() $expectedRuntimeCliSigner = $ExpectedRuntimeSignerThumbprint.ToUpperInvariant() Assert-TrustedVerifierCliUnchanged Assert-TrustedRuntimeCliUnchanged $verifierSignature = Get-AuthenticodeSignature ` -LiteralPath $verifierCliFull $actualVerifierSigner = if ($null -eq $verifierSignature.SignerCertificate) { '' } else { ([string]$verifierSignature.SignerCertificate.Thumbprint). Replace(' ', '').ToUpperInvariant() } if ($verifierSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $actualVerifierSigner -cne $expectedVerifierCliSigner) { Throw-CaptureError 'verifier_cli_signature_invalid' } $runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCliFull $actualRuntimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) { '' } else { ([string]$runtimeSignature.SignerCertificate.Thumbprint). Replace(' ', '').ToUpperInvariant() } if ($runtimeSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $actualRuntimeSigner -cne $expectedRuntimeCliSigner) { Throw-CaptureError 'runtime_cli_signature_invalid' } $runtimeIdentityCorrelation = 'uat-runtime-' + [Guid]::NewGuid().ToString('N') $runtimeIdentityResponse = Invoke-TrustedRuntimeCli @( 'version', '--correlation-id', $runtimeIdentityCorrelation ) $null 'runtime_cli_identity' $runtimeIdentity = $runtimeIdentityResponse.Envelope.data if ($runtimeIdentityResponse.ExitCode -ne 0 -or -not $runtimeIdentityResponse.Envelope.ok -or [string]$runtimeIdentityResponse.Envelope.correlationId -cne $runtimeIdentityCorrelation -or -not (Test-ExactProperties $runtimeIdentity @( 'component', 'version', 'protocolVersion', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or [string]$runtimeIdentity.component -cne 'lserp-agent-cli' -or [string]$runtimeIdentity.version -cne $ExpectedRuntimeCliVersion -or [string]$runtimeIdentity.protocolVersion -cne '1.0' -or $runtimeIdentity.bridgeOnly -isnot [bool] -or $runtimeIdentity.bridgeOnly -ne $true -or $runtimeIdentity.databaseDirectAccess -isnot [bool] -or $runtimeIdentity.databaseDirectAccess -ne $false -or [string]$runtimeIdentity.sessionSource -cne 'current_logged_in_erp_process') { Throw-CaptureError 'runtime_cli_identity_invalid' } $inputFull = Resolve-RegularFile $CommandInputFile (512KB) 'command_input_invalid' $uatAuthorizationFull = Resolve-RegularFile ` $UatAuthorizationFile (512KB) 'uat_authorization_file_invalid' $uatTokenVaultFull = Resolve-RegularFile ` $UatTokenVaultPath (512KB) 'uat_token_vault_file_invalid' if ($inputFull -ieq $uatAuthorizationFull -or $inputFull -ieq $uatTokenVaultFull -or $uatAuthorizationFull -ieq $uatTokenVaultFull -or $verifierCliFull -ieq $inputFull -or $runtimeCliFull -ieq $inputFull -or $verifierCliFull -ieq $uatAuthorizationFull -or $verifierCliFull -ieq $uatTokenVaultFull -or $runtimeCliFull -ieq $uatAuthorizationFull -or $runtimeCliFull -ieq $uatTokenVaultFull) { Throw-CaptureError 'trusted_input_path_conflict' } $uatAuthorizationLock = [IO.File]::Open( $uatAuthorizationFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $uatTokenVaultLock = [IO.File]::Open( $uatTokenVaultFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $actualAuthorizationHash = (Get-FileHash ` -LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualAuthorizationHash -cne $ExpectedUatAuthorizationSha256.ToLowerInvariant()) { Throw-CaptureError 'uat_authorization_hash_mismatch' } $uatVaultSha256 = (Get-FileHash ` -LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant() $uatVerification = Invoke-TrustedVerifierCli @( 'acceptance', 'verify-uat-authorization', '--input', $uatAuthorizationFull, '--correlation-id', ('uat-auth-' + [Guid]::NewGuid().ToString('N')) ) $null 'verify_uat_authorization' if ($uatVerification.ExitCode -ne 0 -or -not $uatVerification.Envelope.ok) { Throw-CaptureError 'uat_authorization_verification_failed' } $uatGrant = Read-VerifiedUatGrant ` $uatVerification.Envelope.data ` $uatAuthorizationFull ` $uatTokenVaultFull ` $CaseCode ` $CommandName $uatToken = [string]$uatGrant.Token $outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid' $auditOutputFull = $null $auditCaseCode = $null if (-not [string]::IsNullOrWhiteSpace($CorrelatedAuditOutputPath)) { if ($CaseCode -eq 'purchase_unique_match_commit') { $auditCaseCode = 'purchase_audit_correlated' } elseif ($CaseCode -eq 'leave_create_draft_commit') { $auditCaseCode = 'leave_audit_correlated' } else { Throw-CaptureError 'correlated_audit_case_invalid' } $auditOutputFull = Resolve-NewJsonPath ` $CorrelatedAuditOutputPath 'correlated_audit_output_invalid' if ($auditOutputFull -ieq $outputFull) { Throw-CaptureError 'correlated_audit_output_invalid' } $uatAuditGrant = Read-VerifiedUatGrant ` $uatVerification.Envelope.data ` $uatAuthorizationFull ` $uatTokenVaultFull ` $auditCaseCode ` $CommandName $uatAuditTokenSha256 = [string]$uatAuditGrant.TokenSha256 $uatAuditGrant.Token = $null $uatAuditGrant = $null } if ([string]::IsNullOrWhiteSpace($RestrictedWorkingRoot)) { if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { Throw-CaptureError 'restricted_working_root_required' } $RestrictedWorkingRoot = Join-Path ` $env:LOCALAPPDATA 'Langsu\Lserp\AcceptanceRaw' } $rawDirectory = New-RestrictedDirectory $RestrictedWorkingRoot $sourceInputCopy = Join-Path $rawDirectory 'source-command-input.json' $inputCopy = Join-Path $rawDirectory 'command-input.json' $preparationResponsePath = Join-Path ` $rawDirectory 'preparation-response.json' $contextResponsePath = Join-Path $rawDirectory 'context-response.json' $planResponsePath = Join-Path $rawDirectory 'plan-response.json' $executeResponsePath = Join-Path $rawDirectory 'execute-response.json' $indexPath = Join-Path $rawDirectory 'case-index.json' [IO.File]::Copy($inputFull, $sourceInputCopy, $false) $postPlanStagingCases = @( 'purchase_runtime_recheck_blocked', 'leave_stale_flow_type_blocked', 'leave_runtime_recheck_blocked' ) $requiresPostPlanStaging = $CaseCode -in $postPlanStagingCases if ($requiresPostPlanStaging -and (-not $Execute -or -not $PauseAfterPlanForOperatorStaging.IsPresent)) { Throw-CaptureError 'post_plan_operator_staging_required' } if ($PauseAfterPlanForOperatorStaging.IsPresent -and (-not $Execute -or $NonInteractive -or -not $requiresPostPlanStaging)) { Throw-CaptureError 'post_plan_operator_staging_mode_invalid' } $hasSecureKey = $null -ne $IdempotencyKey $hasKeyFile = -not [string]::IsNullOrWhiteSpace($IdempotencyKeyFile) if ($hasSecureKey -and $hasKeyFile) { Throw-CaptureError 'idempotency_key_source_conflict' } if (-not $Execute -and ($hasSecureKey -or $hasKeyFile)) { Throw-CaptureError 'idempotency_key_without_execute' } if ($Execute) { if ($hasKeyFile) { $idempotencyPlain = Read-StrictIdempotencyKeyFile $IdempotencyKeyFile } else { if (-not $hasSecureKey) { if ($NonInteractive) { Throw-CaptureError 'idempotency_key_required' } $IdempotencyKey = Read-Host ` '输入本次稳定业务幂等键(不会显示)' -AsSecureString } $idempotencyPlain = Convert-SecureStringToPlainText $IdempotencyKey } if ($idempotencyPlain -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') { Throw-CaptureError 'idempotency_key_invalid' } } $healthCorrelationId = 'uat-health-' + [Guid]::NewGuid().ToString('N') $health = Invoke-TrustedRuntimeCli @( 'bridge', 'health', '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', [string]$uatGrant.DatabaseScopeFingerprint, '--expected-user-id', [string]$uatGrant.UserId, '--expected-user-name', [string]$uatGrant.UserName, '--expected-account-book', [string]$uatGrant.AccountBook, '--expected-subsystem-id', [string]$uatGrant.SubSystemId, '--expected-is-administrator', 'false', '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', $healthCorrelationId ) $null 'bridge_uat_health' $healthUat = if ($null -eq $health.Envelope.data) { $null } else { $health.Envelope.data.workflowUat } if ($health.ExitCode -ne 0 -or -not $health.Envelope.ok -or $null -eq $healthUat -or $healthUat.enabled -ne $true -or [string]$healthUat.sourceSha256 -cne [string]$uatGrant.AuthorizationSourceSha256 -or [string]$healthUat.authorizationIdSha256 -cne [string]$uatGrant.AuthorizationIdSha256 -or $healthUat.generalCapabilitiesHidden -ne $true) { Throw-CaptureError 'bridge_uat_authorization_mismatch' } $correlationId = 'uat-' + [Guid]::NewGuid().ToString('N') $context = Invoke-TrustedRuntimeCli @( 'bridge', 'context', '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', [string]$uatGrant.DatabaseScopeFingerprint, '--expected-user-id', [string]$uatGrant.UserId, '--expected-user-name', [string]$uatGrant.UserName, '--expected-account-book', [string]$uatGrant.AccountBook, '--expected-subsystem-id', [string]$uatGrant.SubSystemId, '--expected-is-administrator', 'false', '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', $correlationId ) $null 'bridge_context' if ($context.ExitCode -ne 0 -or -not $context.Envelope.ok) { Throw-CaptureError 'bridge_context_unavailable' } [IO.File]::WriteAllText($contextResponsePath, $context.Raw, $utf8) if ($null -ne $uatGrant.PreparationCommand) { $preparation = Invoke-TrustedRuntimeCli @( 'bridge', 'plan', [string]$uatGrant.PreparationCommand, '--input', $sourceInputCopy, '--uat-authorization-id', [string]$uatGrant.AuthorizationId, '--uat-case-code', $CaseCode, '--uat-token-stdin', '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', [string]$uatGrant.DatabaseScopeFingerprint, '--expected-user-id', [string]$uatGrant.UserId, '--expected-user-name', [string]$uatGrant.UserName, '--expected-account-book', [string]$uatGrant.AccountBook, '--expected-subsystem-id', [string]$uatGrant.SubSystemId, '--expected-is-administrator', 'false', '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', $correlationId ) $uatToken 'bridge_preparation_plan' [IO.File]::WriteAllText( $preparationResponsePath, $preparation.Raw, $utf8) $resolvedInput = if ($null -eq $preparation.Envelope.data -or $null -eq $preparation.Envelope.data.plan -or $null -eq $preparation.Envelope.data.plan.data) { $null } else { $preparation.Envelope.data.plan.data.resolvedInput } if ($preparation.ExitCode -ne 0 -or -not $preparation.Envelope.ok -or $preparation.Envelope.data.plan.valid -ne $true -or $preparation.Envelope.data.plan.executionAllowed -ne $false -or [string]$preparation.Envelope.data.plan.data.resolvedCommand -cne $CommandName -or $preparation.Envelope.data.plan.data.requiresFollowupPlan -ne $true -or $null -eq $resolvedInput -or $resolvedInput -isnot [pscustomobject] -or [string]$resolvedInput.resolutionProof -cnotmatch '^(?:rp1|lrp1)\.[0-9]{1,19}\.[a-f0-9]{32}\.[a-f0-9]{64}\.[A-Za-z0-9_-]{40,64}$') { Throw-CaptureError 'bridge_preparation_not_resolved' } Write-StrictJson $resolvedInput $inputCopy } else { [IO.File]::Copy($sourceInputCopy, $inputCopy, $false) } $plan = Invoke-TrustedRuntimeCli @( 'bridge', 'plan', $CommandName, '--input', $inputCopy, '--uat-authorization-id', [string]$uatGrant.AuthorizationId, '--uat-case-code', $CaseCode, '--uat-token-stdin', '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', [string]$uatGrant.DatabaseScopeFingerprint, '--expected-user-id', [string]$uatGrant.UserId, '--expected-user-name', [string]$uatGrant.UserName, '--expected-account-book', [string]$uatGrant.AccountBook, '--expected-subsystem-id', [string]$uatGrant.SubSystemId, '--expected-is-administrator', 'false', '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', $correlationId ) $uatToken 'bridge_plan' [IO.File]::WriteAllText($planResponsePath, $plan.Raw, $utf8) $executeResponseFileName = $null if ($Execute) { if ($plan.ExitCode -ne 0 -or -not $plan.Envelope.ok -or $null -eq $plan.Envelope.data.plan -or $plan.Envelope.data.plan.valid -ne $true -or $plan.Envelope.data.plan.executionAllowed -ne $true -or ([string]$plan.Envelope.data.plan.planId) -notmatch '^[A-Fa-f0-9]{32}$') { Throw-CaptureError 'bridge_plan_not_executable' } if ($PauseAfterPlanForOperatorStaging.IsPresent) { $stagingConfirmation = 'STAGED:' + $CaseCode $entered = Read-Host ( '计划已固定;请由授权 DBA/配置人员完成计划后场景变更,' + '保持当前 ERP 进程运行,然后输入 ' + $stagingConfirmation) if ([string]$entered -cne $stagingConfirmation) { Throw-CaptureError 'post_plan_operator_staging_not_confirmed' } Assert-TrustedVerifierCliUnchanged Assert-TrustedRuntimeCliUnchanged $authorizationHashAfterStaging = (Get-FileHash ` -LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant() $vaultHashAfterStaging = (Get-FileHash ` -LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant() if ($authorizationHashAfterStaging -cne $ExpectedUatAuthorizationSha256.ToLowerInvariant() -or $vaultHashAfterStaging -cne $uatVaultSha256) { Throw-CaptureError 'trusted_input_changed_during_operator_staging' } } $execute = Invoke-TrustedRuntimeCli @( 'bridge', 'execute', [string]$plan.Envelope.data.plan.planId, '--idempotency-key-stdin', '--uat-authorization-id', [string]$uatGrant.AuthorizationId, '--uat-case-code', $CaseCode, '--uat-token-stdin', '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', [string]$uatGrant.DatabaseScopeFingerprint, '--expected-user-id', [string]$uatGrant.UserId, '--expected-user-name', [string]$uatGrant.UserName, '--expected-account-book', [string]$uatGrant.AccountBook, '--expected-subsystem-id', [string]$uatGrant.SubSystemId, '--expected-is-administrator', 'false', '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', $correlationId ) ($uatToken + "`n" + $idempotencyPlain) 'bridge_execute' [IO.File]::WriteAllText($executeResponsePath, $execute.Raw, $utf8) $executeResponseFileName = 'execute-response.json' } $mutationCount = Resolve-IntegerObservation ` $BusinessMutationCount 0 1000 ` '输入 DBA 只读复核得到的业务变更行数' ` 'business_mutation_count_required' if ($Execute) { $confirmationObserved = Resolve-BooleanObservation ` $NativeConfirmationObserved ` '是否亲眼看到并处理了 ERP 原生确认窗口' ` 'native_confirmation_observation_required' } else { if ($null -ne $NativeConfirmationObserved -and [bool]$NativeConfirmationObserved) { Throw-CaptureError 'plan_only_confirmation_invalid' } $confirmationObserved = $false } $auditCount = Resolve-IntegerObservation ` $AuditEventCount 1 1000 ` '输入只读复核得到的命令审计事件数' ` 'audit_event_count_required' $sourcePayloadBound = Resolve-BooleanObservation ` $SourceDocumentWritePayloadBound ` '来源附件集合是否已在写过程 payload 中核对一致' ` 'source_payload_observation_required' $sourceAuditCount = Resolve-IntegerObservation ` $SourceDocumentAuditCount 0 1000 ` '输入同一业务审计号下去重后的来源附件审计行数' ` 'source_audit_count_required' $observedAtUtc = [DateTimeOffset]::UtcNow.ToString( "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", [Globalization.CultureInfo]::InvariantCulture) $index = [ordered]@{ schemaVersion = '1.3' caseCode = $CaseCode uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256 uatAuthorizationContentSha256 = [string]$uatGrant.AuthorizationContentSha256 uatAuthorizationIdSha256 = [string]$uatGrant.AuthorizationIdSha256 uatAuthorizationIssuedAtUtc = [string]$uatGrant.AuthorizationIssuedAtUtc uatAuthorizationExpiresAtUtc = [string]$uatGrant.AuthorizationExpiresAtUtc uatExecutionCaseCode = [string]$uatGrant.CaseCode uatTokenSha256 = [string]$uatGrant.TokenSha256 runtimeCliVersion = $ExpectedRuntimeCliVersion runtimeCliSha256 = $expectedRuntimeCliHash runtimeCliSignerThumbprint = $expectedRuntimeCliSigner commandName = $CommandName commandInputFile = 'command-input.json' contextCliResponseFile = 'context-response.json' planCliResponseFile = 'plan-response.json' executeCliResponseFile = $executeResponseFileName idempotencyKey = $idempotencyPlain businessMutationCount = $mutationCount nativeConfirmationObserved = $confirmationObserved auditEventCount = $auditCount sourceDocumentWritePayloadBound = $sourcePayloadBound sourceDocumentAuditCount = $sourceAuditCount observedAtUtc = $observedAtUtc } Write-StrictJson $index $indexPath $mainStage = New-StagedOutput $outputFull Invoke-Projection $indexPath $mainStage 'project_main_case' $auditStage = $null if ($null -ne $auditCaseCode) { $index.caseCode = $auditCaseCode $index.uatTokenSha256 = $uatAuditTokenSha256 $index.businessMutationCount = 0 Write-StrictJson $index $indexPath $auditStage = New-StagedOutput $auditOutputFull Invoke-Projection $indexPath $auditStage 'project_correlated_audit_case' } [IO.File]::Move($mainStage, $outputFull) $publishedOutputs.Add($outputFull) if ($null -ne $auditStage) { [IO.File]::Move($auditStage, $auditOutputFull) $publishedOutputs.Add($auditOutputFull) } Assert-TrustedVerifierCliUnchanged Assert-TrustedRuntimeCliUnchanged if ((Get-FileHash -LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedUatAuthorizationSha256.ToLowerInvariant() -or (Get-FileHash -LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne $uatVaultSha256) { Throw-CaptureError 'uat_locked_input_changed' } $summaryJson = ([ordered]@{ ok = $true packageType = 'workflow_write_case_capture' schemaVersion = '1.2' caseCode = $CaseCode outputFile = [IO.Path]::GetFileName($outputFull) correlatedAuditCaseCode = $auditCaseCode correlatedAuditOutputFile = if ($null -eq $auditOutputFull) { $null } else { [IO.Path]::GetFileName($auditOutputFull) } rawIdentifiersEmitted = $false rawTemporaryFilesRetained = $false uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256 verifierCli = [ordered]@{ fileName = 'lserp-cli.exe' sha256 = $expectedVerifierCliHash signerThumbprint = $expectedVerifierCliSigner } runtimeCli = [ordered]@{ fileName = 'lserp-agent-cli.exe' version = $ExpectedRuntimeCliVersion sha256 = $expectedRuntimeCliHash signerThumbprint = $expectedRuntimeCliSigner bridgeOnly = $true databaseDirectAccess = $false sessionSource = 'current_logged_in_erp_process' } uatCaseTokenEmitted = $false registrationReady = $false } | ConvertTo-Json -Depth 4) } catch { foreach ($published in @($publishedOutputs)) { try { if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) } } catch { } } throw } finally { $idempotencyPlain = $null $uatToken = $null $uatAuditTokenSha256 = $null $cleanupFailed = $false if ($null -ne $verifierCliLock) { try { $verifierCliLock.Dispose() } catch { $cleanupFailed = $true } } if ($null -ne $runtimeCliLock) { try { $runtimeCliLock.Dispose() } catch { $cleanupFailed = $true } } if ($null -ne $uatAuthorizationLock) { try { $uatAuthorizationLock.Dispose() } catch { $cleanupFailed = $true } } if ($null -ne $uatTokenVaultLock) { try { $uatTokenVaultLock.Dispose() } catch { $cleanupFailed = $true } } foreach ($staged in @($stagedOutputs)) { try { if ([IO.File]::Exists($staged)) { [IO.File]::Delete($staged) } } catch { $cleanupFailed = $true } } if ($null -ne $rawDirectory -and [IO.Directory]::Exists($rawDirectory)) { try { [IO.Directory]::Delete($rawDirectory, $true) } catch { $cleanupFailed = $true } } if ($cleanupFailed) { foreach ($published in @($publishedOutputs)) { try { if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) } } catch { } } throw 'workflow_write_case_capture_failed:cleanup_failed' } } Write-Output $summaryJson