feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedInputSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
|
||||
[string]$PreflightScriptPath = '',
|
||||
|
||||
[switch]$ValidateInputOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$strictUtf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$inputLock = $null
|
||||
$preflightLock = $null
|
||||
$outputFull = $null
|
||||
$published = $false
|
||||
$executionAttempted = $false
|
||||
$safeSha256 = '^[0-9a-f]{64}$'
|
||||
$safeCliVersion = '^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$'
|
||||
$safeModuleCode = '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$'
|
||||
|
||||
function Throw-FieldValidationError([string]$Code) {
|
||||
throw ('lserp_field_readonly_validation_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($actual.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($actual -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-JsonArray([object]$Value) {
|
||||
return $null -ne $Value -and $Value -is [array]
|
||||
}
|
||||
|
||||
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-FieldValidationError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RegularFile(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) {
|
||||
Throw-FieldValidationError $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-FieldValidationError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain `
|
||||
([IO.Path]::GetDirectoryName($full)) $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $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-FieldValidationError $Code
|
||||
}
|
||||
$directory = [IO.Path]::GetDirectoryName($full)
|
||||
Assert-NoReparseDirectoryChain $directory $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $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-StreamSha256([IO.Stream]$Stream) {
|
||||
$position = $Stream.Position
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Stream.Position = 0
|
||||
return ([BitConverter]::ToString(
|
||||
$sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$Stream.Position = $position
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ScopedValueSha256([string]$Scope, [string]$Value) {
|
||||
return Get-Sha256Hex ($strictUtf8.GetBytes(
|
||||
'lserp-field-readonly-validation-v1|' + $Scope + '|' + $Value))
|
||||
}
|
||||
|
||||
function Read-LockedUtf8Bytes(
|
||||
[IO.FileStream]$Stream,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
if ($Stream.Length -le 0 -or $Stream.Length -gt $MaximumBytes -or
|
||||
$Stream.Length -gt [int]::MaxValue) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
$bytes = New-Object byte[] ([int]$Stream.Length)
|
||||
$Stream.Position = 0
|
||||
$offset = 0
|
||||
while ($offset -lt $bytes.Length) {
|
||||
$read = $Stream.Read($bytes, $offset, $bytes.Length - $offset)
|
||||
if ($read -le 0) { Throw-FieldValidationError $Code }
|
||||
$offset += $read
|
||||
}
|
||||
if ($bytes.Length -ge 3 -and
|
||||
$bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and
|
||||
$bytes[2] -eq 0xBF) {
|
||||
Throw-FieldValidationError ($Code + '_utf8_bom_forbidden')
|
||||
}
|
||||
return $bytes
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-StrictJsonText([string]$Text) {
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
$jsonReader.SupportMultipleContent = $true
|
||||
$stack = New-Object Collections.Stack
|
||||
$rootValues = 0
|
||||
try {
|
||||
while ($jsonReader.Read()) {
|
||||
$token = [string]$jsonReader.TokenType
|
||||
if ($token -eq 'Comment') {
|
||||
Throw-FieldValidationError 'input_json_comment_forbidden'
|
||||
}
|
||||
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
$names = if ($token -eq 'StartObject') {
|
||||
New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
} else { $null }
|
||||
$stack.Push([pscustomobject]@{
|
||||
Kind = if ($token -eq 'StartObject') {
|
||||
'object'
|
||||
} else { 'array' }
|
||||
Names = $names
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
|
||||
if ($stack.Count -eq 0) {
|
||||
Throw-FieldValidationError 'input_json_nesting_invalid'
|
||||
}
|
||||
$expectedKind = if ($token -eq 'EndObject') {
|
||||
'object'
|
||||
} else { 'array' }
|
||||
if ([string]$stack.Peek().Kind -cne $expectedKind) {
|
||||
Throw-FieldValidationError 'input_json_nesting_invalid'
|
||||
}
|
||||
[void]$stack.Pop()
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'PropertyName') {
|
||||
if ($stack.Count -eq 0 -or
|
||||
[string]$stack.Peek().Kind -cne 'object' -or
|
||||
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
|
||||
Throw-FieldValidationError `
|
||||
'input_json_duplicate_property'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
}
|
||||
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
|
||||
Throw-FieldValidationError 'input_json_root_invalid'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError 'input_json_invalid'
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
|
||||
$insideString = $false
|
||||
$escaped = $false
|
||||
for ($index = 0; $index -lt $Text.Length; $index++) {
|
||||
$character = $Text[$index]
|
||||
if ($insideString) {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
if ($character -eq '"') { $insideString = $false }
|
||||
continue
|
||||
}
|
||||
if ($character -eq '"') { $insideString = $true; continue }
|
||||
if ($character -ne ',') { continue }
|
||||
$next = $index + 1
|
||||
while ($next -lt $Text.Length -and
|
||||
[char]::IsWhiteSpace($Text[$next])) { $next++ }
|
||||
if ($next -lt $Text.Length -and
|
||||
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
|
||||
Throw-FieldValidationError 'input_json_trailing_comma'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-StrictRootObjectToken([string]$Text) {
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
try {
|
||||
$token = [Newtonsoft.Json.Linq.JToken]::ReadFrom($jsonReader)
|
||||
if ($token -isnot [Newtonsoft.Json.Linq.JObject]) {
|
||||
Throw-FieldValidationError 'input_json_root_invalid'
|
||||
}
|
||||
Write-Output -NoEnumerate $token
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError 'input_json_invalid'
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-RawTopLevelString(
|
||||
[Newtonsoft.Json.Linq.JObject]$Root,
|
||||
[string]$Name,
|
||||
[string]$Code
|
||||
) {
|
||||
$token = $Root.GetValue($Name, [StringComparison]::Ordinal)
|
||||
if ($null -eq $token -or
|
||||
$token.Type -ne [Newtonsoft.Json.Linq.JTokenType]::String) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
return [string]$token.Value
|
||||
}
|
||||
|
||||
function Assert-SafeText(
|
||||
[object]$Raw,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength,
|
||||
[string]$Code
|
||||
) {
|
||||
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
||||
$value = [string]$Raw
|
||||
if ($value.Length -lt $MinimumLength -or
|
||||
$value.Length -gt $MaximumLength -or
|
||||
$value -cne $value.Trim()) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
foreach ($character in $value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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-FieldValidationError $Code
|
||||
}
|
||||
return [int]$Raw
|
||||
}
|
||||
|
||||
function Read-ApprovalUtc([object]$Raw, [string]$Code) {
|
||||
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
||||
try {
|
||||
$value = [DateTimeOffset]::ParseExact(
|
||||
[string]$Raw,
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
[Globalization.DateTimeStyles]::AssumeUniversal `
|
||||
-bor [Globalization.DateTimeStyles]::AdjustToUniversal)
|
||||
}
|
||||
catch { Throw-FieldValidationError $Code }
|
||||
if ($value.ToUniversalTime().ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture) -cne
|
||||
[string]$Raw) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
return $value.ToUniversalTime()
|
||||
}
|
||||
|
||||
function Write-NewUtf8File([string]$Path, [string]$Text) {
|
||||
$stream = $null
|
||||
$writer = $null
|
||||
$created = $false
|
||||
$succeeded = $false
|
||||
$failure = $null
|
||||
try {
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None)
|
||||
$created = $true
|
||||
$writer = New-Object IO.StreamWriter($stream, $strictUtf8)
|
||||
$writer.Write($Text)
|
||||
$writer.Flush()
|
||||
$stream.Flush($true)
|
||||
$succeeded = $true
|
||||
}
|
||||
catch { $failure = $_ }
|
||||
finally {
|
||||
if ($null -ne $writer) { $writer.Dispose() }
|
||||
elseif ($null -ne $stream) { $stream.Dispose() }
|
||||
if ($created -and -not $succeeded -and [IO.File]::Exists($Path)) {
|
||||
try { [IO.File]::Delete($Path) } catch { }
|
||||
}
|
||||
}
|
||||
if ($null -ne $failure -or -not $succeeded) {
|
||||
Throw-FieldValidationError 'output_publish_failed'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$inputFull = Resolve-RegularFile $InputPath 256KB 'input_file_invalid'
|
||||
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
|
||||
if ([string]::IsNullOrWhiteSpace($PreflightScriptPath)) {
|
||||
$PreflightScriptPath = Join-Path $PSScriptRoot `
|
||||
'Invoke-LserpReadOnlySessionPreflight.ps1'
|
||||
}
|
||||
$preflightFull = Resolve-RegularFile `
|
||||
$PreflightScriptPath 2MB 'preflight_script_invalid'
|
||||
if ([IO.Path]::GetFileName($preflightFull) -cne
|
||||
'Invoke-LserpReadOnlySessionPreflight.ps1' -or
|
||||
$inputFull -ieq $outputFull -or
|
||||
$inputFull -ieq $preflightFull -or
|
||||
$outputFull -ieq $preflightFull) {
|
||||
Throw-FieldValidationError 'trusted_path_conflict'
|
||||
}
|
||||
|
||||
$inputLock = [IO.File]::Open(
|
||||
$inputFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$inputBytes = Read-LockedUtf8Bytes `
|
||||
$inputLock 256KB 'input_file_invalid'
|
||||
$inputHash = Get-Sha256Hex $inputBytes
|
||||
if ($inputHash -cne $ExpectedInputSha256.ToLowerInvariant()) {
|
||||
Throw-FieldValidationError 'input_hash_mismatch'
|
||||
}
|
||||
try { $inputText = $strictUtf8.GetString($inputBytes) }
|
||||
catch { Throw-FieldValidationError 'input_utf8_invalid' }
|
||||
Assert-StrictJsonText $inputText
|
||||
$rawDocument = Read-StrictRootObjectToken $inputText
|
||||
try { $document = $inputText | ConvertFrom-Json }
|
||||
catch { Throw-FieldValidationError 'input_json_invalid' }
|
||||
|
||||
$topLevelProperties = @(
|
||||
'schemaVersion', 'evidenceType', 'validationStage', 'approved',
|
||||
'approvedBy', 'approvedAtUtc', 'approvalExpiresAtUtc',
|
||||
'expectedPreflightScriptSha256', 'cli', 'erp', 'session',
|
||||
'rollout', 'moduleBindings', 'requirements',
|
||||
'bridgeTimeoutMilliseconds', 'databaseCredentialsIncluded',
|
||||
'readOnlyEvidenceOutputPath'
|
||||
)
|
||||
if (-not (Test-ExactProperties $document $topLevelProperties) -or
|
||||
[string]$document.schemaVersion -cne '1.1' -or
|
||||
[string]$document.evidenceType -cne
|
||||
'lserp_field_readonly_validation_input' -or
|
||||
([string]$document.validationStage) -cnotin @(
|
||||
'discovery', 'final') -or
|
||||
$document.approved -isnot [bool] -or
|
||||
$document.approved -ne $true -or
|
||||
$document.databaseCredentialsIncluded -isnot [bool] -or
|
||||
$document.databaseCredentialsIncluded -ne $false) {
|
||||
Throw-FieldValidationError 'input_contract_invalid'
|
||||
}
|
||||
|
||||
Assert-SafeText $document.approvedBy 1 128 'approval_identity_invalid'
|
||||
if ([string]$document.approvedBy -cmatch '(?i)^replace(?:_|$)') {
|
||||
Throw-FieldValidationError 'approval_placeholder_forbidden'
|
||||
}
|
||||
$approvedAt = Read-ApprovalUtc `
|
||||
(Read-RawTopLevelString `
|
||||
$rawDocument 'approvedAtUtc' 'approval_time_invalid') `
|
||||
'approval_time_invalid'
|
||||
$approvalExpiresAt = Read-ApprovalUtc `
|
||||
(Read-RawTopLevelString `
|
||||
$rawDocument 'approvalExpiresAtUtc' 'approval_expiry_invalid') `
|
||||
'approval_expiry_invalid'
|
||||
$now = [DateTimeOffset]::UtcNow
|
||||
if ($approvedAt -gt $now.AddMinutes(5) -or
|
||||
$approvalExpiresAt -le $approvedAt -or
|
||||
$approvalExpiresAt -gt $approvedAt.AddHours(24) -or
|
||||
$approvalExpiresAt -le $now) {
|
||||
Throw-FieldValidationError 'approval_window_invalid'
|
||||
}
|
||||
|
||||
$expectedPreflightHash =
|
||||
([string]$document.expectedPreflightScriptSha256).ToLowerInvariant()
|
||||
if ($expectedPreflightHash -cnotmatch $safeSha256 -or
|
||||
$expectedPreflightHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'preflight_hash_invalid'
|
||||
}
|
||||
|
||||
if (-not (Test-ExactProperties $document.cli @(
|
||||
'path', 'version', 'sha256', 'signerThumbprint')) -or
|
||||
-not (Test-ExactProperties $document.erp @(
|
||||
'processId', 'sha256')) -or
|
||||
-not (Test-ExactProperties $document.session @(
|
||||
'databaseScopeFingerprint', 'userId', 'userName', 'accountBook',
|
||||
'subSystemId', 'expectedIsAdministrator')) -or
|
||||
-not (Test-ExactProperties $document.rollout @(
|
||||
'customerId', 'policySha256')) -or
|
||||
-not (Test-ExactProperties $document.requirements @(
|
||||
'purchaseWorkflow', 'leaveWorkflow',
|
||||
'diagnosisWorkflow'))) {
|
||||
Throw-FieldValidationError 'input_nested_contract_invalid'
|
||||
}
|
||||
|
||||
Assert-SafeText $document.cli.path 1 1024 'cli_path_invalid'
|
||||
if ([string]$document.cli.path -cnotmatch
|
||||
'(?i)(?:^|[\\/])lserp-agent-cli\.exe$' -or
|
||||
[string]$document.cli.path -cmatch '(?i)replace(?:_|[\\/])') {
|
||||
Throw-FieldValidationError 'cli_path_invalid'
|
||||
}
|
||||
$cliVersion = [string]$document.cli.version
|
||||
$cliHash = ([string]$document.cli.sha256).ToLowerInvariant()
|
||||
$signerThumbprint = [string]$document.cli.signerThumbprint
|
||||
if ($cliVersion -cnotmatch $safeCliVersion -or
|
||||
$cliHash -cnotmatch $safeSha256 -or
|
||||
$cliHash -ceq ('0' * 64) -or
|
||||
$signerThumbprint -cnotmatch '^[A-F0-9]{40}$' -or
|
||||
$signerThumbprint -ceq ('0' * 40)) {
|
||||
Throw-FieldValidationError 'cli_identity_invalid'
|
||||
}
|
||||
|
||||
$erpProcessId = Read-JsonInt32 `
|
||||
$document.erp.processId 1 2147483647 'erp_process_id_invalid'
|
||||
$erpHash = ([string]$document.erp.sha256).ToLowerInvariant()
|
||||
if ($erpHash -cnotmatch $safeSha256 -or
|
||||
$erpHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'erp_hash_invalid'
|
||||
}
|
||||
|
||||
$databaseScope =
|
||||
([string]$document.session.databaseScopeFingerprint).ToLowerInvariant()
|
||||
if ($databaseScope -cnotmatch $safeSha256 -or
|
||||
$databaseScope -ceq ('0' * 64) -or
|
||||
$document.session.expectedIsAdministrator -isnot [bool]) {
|
||||
Throw-FieldValidationError 'session_scope_invalid'
|
||||
}
|
||||
foreach ($property in @(
|
||||
'userId', 'userName', 'accountBook', 'subSystemId')) {
|
||||
Assert-SafeText `
|
||||
$document.session.$property 1 256 'session_scope_invalid'
|
||||
if ([string]$document.session.$property -cmatch
|
||||
'(?i)^replace(?:_|$)') {
|
||||
Throw-FieldValidationError 'session_scope_placeholder_forbidden'
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SafeText $document.rollout.customerId 1 128 `
|
||||
'rollout_identity_invalid'
|
||||
$rolloutCustomerId = [string]$document.rollout.customerId
|
||||
$rolloutPolicyHash =
|
||||
([string]$document.rollout.policySha256).ToLowerInvariant()
|
||||
if ($rolloutCustomerId -cnotmatch
|
||||
'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$' -or
|
||||
$rolloutCustomerId -cmatch '(?i)^replace(?:_|$)' -or
|
||||
$rolloutPolicyHash -cnotmatch $safeSha256 -or
|
||||
$rolloutPolicyHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'rollout_identity_invalid'
|
||||
}
|
||||
|
||||
foreach ($name in @(
|
||||
'purchaseWorkflow', 'leaveWorkflow', 'diagnosisWorkflow')) {
|
||||
if ($document.requirements.$name -isnot [bool]) {
|
||||
Throw-FieldValidationError 'workflow_requirements_invalid'
|
||||
}
|
||||
}
|
||||
$requirePurchase = [bool]$document.requirements.purchaseWorkflow
|
||||
$requireLeave = [bool]$document.requirements.leaveWorkflow
|
||||
$requireDiagnosis = [bool]$document.requirements.diagnosisWorkflow
|
||||
if ($requireDiagnosis -and
|
||||
-not [bool]$document.session.expectedIsAdministrator) {
|
||||
Throw-FieldValidationError `
|
||||
'diagnosis_requires_expected_administrator'
|
||||
}
|
||||
|
||||
if (-not (Test-JsonArray $document.moduleBindings)) {
|
||||
Throw-FieldValidationError 'module_bindings_invalid'
|
||||
}
|
||||
$rawBindings = @($document.moduleBindings)
|
||||
if ($rawBindings.Count -lt 1 -or $rawBindings.Count -gt 16) {
|
||||
Throw-FieldValidationError 'module_bindings_invalid'
|
||||
}
|
||||
$pairSet = New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
$moduleSet = New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::OrdinalIgnoreCase)
|
||||
$moduleCodes = New-Object System.Collections.Generic.List[string]
|
||||
$normalizedBindings = New-Object System.Collections.Generic.List[string]
|
||||
$roleCounts = [ordered]@{
|
||||
purchase = 0
|
||||
leave = 0
|
||||
diagnosis = 0
|
||||
support = 0
|
||||
}
|
||||
foreach ($binding in $rawBindings) {
|
||||
if (-not (Test-ExactProperties $binding @('role', 'moduleCode')) -or
|
||||
$binding.role -isnot [string] -or
|
||||
$binding.moduleCode -isnot [string]) {
|
||||
Throw-FieldValidationError 'module_binding_contract_invalid'
|
||||
}
|
||||
$role = [string]$binding.role
|
||||
$moduleCode = [string]$binding.moduleCode
|
||||
if ($role -cnotin @('purchase', 'leave', 'diagnosis', 'support') -or
|
||||
$moduleCode -cnotmatch $safeModuleCode -or
|
||||
$moduleCode -cmatch '(?i)^replace(?:_|$)' -or
|
||||
-not $pairSet.Add($role + '|' + $moduleCode.ToUpperInvariant())) {
|
||||
Throw-FieldValidationError 'module_binding_contract_invalid'
|
||||
}
|
||||
$roleCounts[$role] = [int]$roleCounts[$role] + 1
|
||||
$normalizedBindings.Add($role + '|' + $moduleCode)
|
||||
if ($moduleSet.Add($moduleCode)) { $moduleCodes.Add($moduleCode) }
|
||||
}
|
||||
if ($moduleCodes.Count -lt 1 -or $moduleCodes.Count -gt 16 -or
|
||||
($requirePurchase -and [int]$roleCounts.purchase -eq 0) -or
|
||||
($requireLeave -and [int]$roleCounts.leave -eq 0) -or
|
||||
($requireDiagnosis -and [int]$roleCounts.diagnosis -eq 0)) {
|
||||
Throw-FieldValidationError 'required_module_role_missing'
|
||||
}
|
||||
if ([string]$document.validationStage -ceq 'final') {
|
||||
if (-not ($requirePurchase -or $requireLeave -or $requireDiagnosis) -or
|
||||
([int]$roleCounts.purchase -gt 0) -ne $requirePurchase -or
|
||||
([int]$roleCounts.leave -gt 0) -ne $requireLeave -or
|
||||
([int]$roleCounts.diagnosis -gt 0) -ne $requireDiagnosis) {
|
||||
Throw-FieldValidationError 'final_workflow_role_mismatch'
|
||||
}
|
||||
}
|
||||
|
||||
$bridgeTimeout = Read-JsonInt32 `
|
||||
$document.bridgeTimeoutMilliseconds 1000 300000 `
|
||||
'bridge_timeout_invalid'
|
||||
Assert-SafeText $document.readOnlyEvidenceOutputPath 1 1024 `
|
||||
'readonly_evidence_output_path_invalid'
|
||||
$readOnlyEvidenceOutputPath =
|
||||
[string]$document.readOnlyEvidenceOutputPath
|
||||
if ($readOnlyEvidenceOutputPath -cnotmatch '(?i)\.json$' -or
|
||||
$readOnlyEvidenceOutputPath -cmatch '(?i)replace(?:_|[\\/])') {
|
||||
Throw-FieldValidationError 'readonly_evidence_output_path_invalid'
|
||||
}
|
||||
|
||||
$preflightLock = [IO.File]::Open(
|
||||
$preflightFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$actualPreflightHash = Get-StreamSha256 $preflightLock
|
||||
if ($actualPreflightHash -cne $expectedPreflightHash) {
|
||||
Throw-FieldValidationError 'preflight_hash_mismatch'
|
||||
}
|
||||
|
||||
$orderedBindings = $normalizedBindings.ToArray()
|
||||
[Array]::Sort($orderedBindings, [StringComparer]::Ordinal)
|
||||
$moduleBindingFingerprint = Get-ScopedValueSha256 `
|
||||
'module-bindings' ($orderedBindings -join "`n")
|
||||
$sessionApprovalFingerprint = Get-ScopedValueSha256 `
|
||||
'session-approval' (@(
|
||||
$databaseScope,
|
||||
[string]$document.session.userId,
|
||||
[string]$document.session.userName,
|
||||
[string]$document.session.accountBook,
|
||||
[string]$document.session.subSystemId,
|
||||
([string][bool]$document.session.expectedIsAdministrator)
|
||||
) -join "`n")
|
||||
$handoffContractSha256 = Get-ScopedValueSha256 `
|
||||
'preflight-handoff' (@(
|
||||
$expectedPreflightHash,
|
||||
[string]$document.cli.path,
|
||||
$cliVersion,
|
||||
$cliHash,
|
||||
$signerThumbprint,
|
||||
[string]$erpProcessId,
|
||||
$erpHash,
|
||||
$databaseScope,
|
||||
[string]$document.session.userId,
|
||||
[string]$document.session.userName,
|
||||
[string]$document.session.accountBook,
|
||||
[string]$document.session.subSystemId,
|
||||
([string][bool]$document.session.expectedIsAdministrator),
|
||||
$rolloutCustomerId,
|
||||
$rolloutPolicyHash,
|
||||
($moduleCodes.ToArray() -join ','),
|
||||
([string]$requirePurchase),
|
||||
([string]$requireLeave),
|
||||
([string]$requireDiagnosis),
|
||||
[string]$bridgeTimeout,
|
||||
$readOnlyEvidenceOutputPath
|
||||
) -join "`n")
|
||||
|
||||
$preflightEvidenceHash = $null
|
||||
if (-not $ValidateInputOnly) {
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
Throw-FieldValidationError `
|
||||
'windows_powershell_51_required_for_execution'
|
||||
}
|
||||
$preflightEvidenceFull = Resolve-NewJsonPath `
|
||||
$readOnlyEvidenceOutputPath 'readonly_evidence_output_path_invalid'
|
||||
foreach ($trustedPath in @(
|
||||
$inputFull, $outputFull, $preflightFull)) {
|
||||
if ($preflightEvidenceFull -ieq $trustedPath) {
|
||||
Throw-FieldValidationError 'trusted_path_conflict'
|
||||
}
|
||||
}
|
||||
$preflightArguments = @{
|
||||
CliPath = [string]$document.cli.path
|
||||
ExpectedCliVersion = $cliVersion
|
||||
ExpectedCliSha256 = $cliHash
|
||||
ExpectedSignerThumbprint = $signerThumbprint
|
||||
ErpProcessId = $erpProcessId
|
||||
ExpectedErpSha256 = $erpHash
|
||||
ExpectedDatabaseScopeFingerprint = $databaseScope
|
||||
ExpectedUserId = [string]$document.session.userId
|
||||
ExpectedUserName = [string]$document.session.userName
|
||||
ExpectedAccountBook = [string]$document.session.accountBook
|
||||
ExpectedSubSystemId = [string]$document.session.subSystemId
|
||||
ExpectedIsAdministrator =
|
||||
[bool]$document.session.expectedIsAdministrator
|
||||
ExpectedRolloutCustomerId = $rolloutCustomerId
|
||||
ExpectedRolloutPolicySha256 = $rolloutPolicyHash
|
||||
ModuleCodes = $moduleCodes.ToArray()
|
||||
OutputPath = $preflightEvidenceFull
|
||||
BridgeTimeoutMilliseconds = $bridgeTimeout
|
||||
}
|
||||
if ($requirePurchase) {
|
||||
$preflightArguments.RequirePurchaseWorkflow = $true
|
||||
}
|
||||
if ($requireLeave) {
|
||||
$preflightArguments.RequireLeaveWorkflow = $true
|
||||
}
|
||||
if ($requireDiagnosis) {
|
||||
$preflightArguments.RequireDiagnosisWorkflow = $true
|
||||
}
|
||||
$executionAttempted = $true
|
||||
$preflightOutput = @(& $preflightFull @preflightArguments)
|
||||
if ($preflightOutput.Count -ne 1 -or
|
||||
$preflightOutput[0] -isnot [string] -or
|
||||
[IO.Path]::GetFullPath([string]$preflightOutput[0]) -ine
|
||||
$preflightEvidenceFull) {
|
||||
Throw-FieldValidationError 'preflight_output_invalid'
|
||||
}
|
||||
$evidenceFull = Resolve-RegularFile `
|
||||
$preflightEvidenceFull 4MB 'preflight_evidence_invalid'
|
||||
$evidenceLock = $null
|
||||
try {
|
||||
$evidenceLock = [IO.File]::Open(
|
||||
$evidenceFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$evidenceBytes = Read-LockedUtf8Bytes `
|
||||
$evidenceLock 4MB 'preflight_evidence_invalid'
|
||||
try { $evidenceText = $strictUtf8.GetString($evidenceBytes) }
|
||||
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
||||
Assert-StrictJsonText $evidenceText
|
||||
try { $evidence = $evidenceText | ConvertFrom-Json }
|
||||
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
||||
if ([string]$evidence.schemaVersion -cne '1.5' -or
|
||||
[string]$evidence.evidenceType -cne
|
||||
'lserp_readonly_session_preflight' -or
|
||||
$evidence.passed -ne $true -or
|
||||
$evidence.readOnlySessionReady -ne $true -or
|
||||
$evidence.productionWriteAuthorized -ne $false -or
|
||||
-not (Test-ExactProperties $evidence.cli @(
|
||||
'component', 'version', 'protocolVersion',
|
||||
'bridgeOnly', 'databaseDirectAccess', 'sessionSource',
|
||||
'sha256', 'signerThumbprint')) -or
|
||||
[string]$evidence.cli.component -cne 'lserp-agent-cli' -or
|
||||
[string]$evidence.cli.version -cne $cliVersion -or
|
||||
[string]$evidence.cli.protocolVersion -cne '1.0' -or
|
||||
$evidence.cli.bridgeOnly -ne $true -or
|
||||
$evidence.cli.databaseDirectAccess -ne $false -or
|
||||
[string]$evidence.cli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
[string]$evidence.cli.sha256 -cne $cliHash -or
|
||||
[string]$evidence.cli.signerThumbprint -cne
|
||||
$signerThumbprint) {
|
||||
Throw-FieldValidationError 'preflight_evidence_invalid'
|
||||
}
|
||||
$preflightEvidenceHash = Get-Sha256Hex $evidenceBytes
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $evidenceLock) { $evidenceLock.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
if ((Get-StreamSha256 $inputLock) -cne $inputHash) {
|
||||
Throw-FieldValidationError 'input_changed_during_validation'
|
||||
}
|
||||
if ((Get-StreamSha256 $preflightLock) -cne $expectedPreflightHash) {
|
||||
Throw-FieldValidationError 'preflight_changed_during_validation'
|
||||
}
|
||||
|
||||
$report = [ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
evidenceType = 'lserp_field_readonly_validation_handoff'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
passed = $true
|
||||
validationStage = [string]$document.validationStage
|
||||
inputSha256 = $inputHash
|
||||
approvedBySha256 = Get-ScopedValueSha256 `
|
||||
'approved-by' ([string]$document.approvedBy)
|
||||
approvedAtUtc = $approvedAt.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
approvalExpiresAtUtc = $approvalExpiresAt.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
expectedPreflightScriptSha256 = $expectedPreflightHash
|
||||
handoffContractSha256 = $handoffContractSha256
|
||||
runtimeCli = [ordered]@{
|
||||
component = 'lserp-agent-cli'
|
||||
version = $cliVersion
|
||||
sha256 = $cliHash
|
||||
signerThumbprint = $signerThumbprint
|
||||
}
|
||||
sessionApprovalFingerprint = $sessionApprovalFingerprint
|
||||
erpProcessId = $erpProcessId
|
||||
expectedIsAdministrator =
|
||||
[bool]$document.session.expectedIsAdministrator
|
||||
moduleBindingFingerprint = $moduleBindingFingerprint
|
||||
moduleBindingCount = $rawBindings.Count
|
||||
uniqueModuleCount = $moduleCodes.Count
|
||||
moduleRoleCounts = $roleCounts
|
||||
requestedReadiness = [ordered]@{
|
||||
purchaseWorkflow = $requirePurchase
|
||||
leaveWorkflow = $requireLeave
|
||||
diagnosisWorkflow = $requireDiagnosis
|
||||
}
|
||||
databaseCredentialsIncluded = $false
|
||||
validationOnly = [bool]$ValidateInputOnly
|
||||
executionAttempted = $executionAttempted
|
||||
preflightEvidenceProduced = -not [bool]$ValidateInputOnly
|
||||
preflightEvidenceSha256 = $preflightEvidenceHash
|
||||
productionWriteAuthorized = $false
|
||||
readOnlyBoundary = [ordered]@{
|
||||
directDatabaseConnectionUsed = $false
|
||||
databaseCredentialAccepted = $false
|
||||
businessWriteAttempted = $false
|
||||
rawSessionValuesEmitted = $false
|
||||
rawModuleCodesEmitted = $false
|
||||
preflightExecuteCommandAllowed = $false
|
||||
}
|
||||
checks = @(
|
||||
[ordered]@{ code = 'strict_json'; passed = $true },
|
||||
[ordered]@{ code = 'input_integrity'; passed = $true },
|
||||
[ordered]@{ code = 'independent_approval_window'; passed = $true },
|
||||
[ordered]@{ code = 'database_credentials_absent'; passed = $true },
|
||||
[ordered]@{ code = 'exact_session_expectations'; passed = $true },
|
||||
[ordered]@{ code = 'dynamic_module_role_bindings'; passed = $true },
|
||||
[ordered]@{ code = 'preflight_source_integrity'; passed = $true },
|
||||
[ordered]@{ code = 'readonly_parameter_handoff'; passed = $true }
|
||||
)
|
||||
note = '本报告证明经独立审核并锁定哈希的 PID、会话范围和动态模块角色已完整交给只读预检;它不包含数据库凭据,不自动信任 Bridge 自报范围,也不授权任何业务写入。'
|
||||
}
|
||||
Write-NewUtf8File `
|
||||
$outputFull (($report | ConvertTo-Json -Depth 8) +
|
||||
[Environment]::NewLine)
|
||||
$published = $true
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $preflightLock) { $preflightLock.Dispose() }
|
||||
if ($null -ne $inputLock) { $inputLock.Dispose() }
|
||||
if (-not $published -and $null -ne $outputFull -and
|
||||
[IO.File]::Exists($outputFull)) {
|
||||
try { [IO.File]::Delete($outputFull) } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output $outputFull
|
||||
Reference in New Issue
Block a user