From 1bb7a5eb4b3d736df4452bfd31d864b26c0ecd4c Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:37:49 +0800 Subject: [PATCH 01/12] fix(deploy): resolve Kit control URL after the .env missing-key merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 1 audit sealed $resolvedKitControlUrl and the Kit Manager runtime signature before the Phase 2 ".env / .env.example missing-key merge" ran. That merge can both append a missing KIT_CONTROL_URL and repoint $resolvedEnvFile from the .example to the real env file, so a run that repaired the file still started the host-native Kit Manager with the stale pre-merge value and then persisted a signature describing the repaired state — a blocked runtime-control state that reads as configured on the next run. Pure relocation, not duplication: both assignments move down to immediately after the env merge and volume fix. Nothing between the old and new positions reads either variable, so the exactly-once AST guarantee asserted by test-deploy-governance-static.ps1 is preserved. $resolvedAllowedStageHosts is deliberately left where it is. test-deploy-governance-static.ps1 gains index-ordering assertions that pin the resolve, the signature build and the child launch after the merge marker. Known delta: -DryRun exits before Phase 2, so it no longer validates KIT_CONTROL_URL. The value it used to validate was the pre-merge one read from whichever file the audit resolved, which is the stale read this change removes. Refs #490 Co-Authored-By: Claude Fable 5 --- scripts/deploy.ps1 | 39 +++++++++++++------ .../tests/test-deploy-governance-static.ps1 | 28 +++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 086cbc50c..792c35203 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -888,12 +888,14 @@ if (-not (Test-KitRuntimeSignatureMatches -Path $script:webPlaneRuntimeSignature $shouldRefreshWebPlane = $true } $resolvedConversionHealthHost = Resolve-HealthProbeHost -BindHost $resolvedConversionBindHost -$resolvedKitControlUrl = if ($SkipKitManager) { - '' -} else { - Resolve-HostNativeKitControlUrl ` - -KitControlUrl (Get-DeployEnvValue -Name 'KIT_CONTROL_URL' -EnvFile $resolvedEnvFile -Default '').Trim() -} +# NOTE: $resolvedKitControlUrl and $kitManagerRuntimeSignature are deliberately +# NOT resolved here. Both derive from KIT_CONTROL_URL in $resolvedEnvFile, and the +# Phase 2 ".env / .env.example missing-key merge" below can still append that key +# and repoint $resolvedEnvFile at the real env file. Resolving here sealed the +# pre-merge value into the child launch and the persisted runtime signature, so a +# run that repaired the file still started blocked while claiming to be +# configured. They now live immediately after that merge; nothing between here +# and there reads either variable. $resolvedAllowedStageHosts = Resolve-AllowedStageHosts -EnvFile $resolvedEnvFile -PublicHost $resolvedPublicHost -ConversionPort 49101 $kitRuntimeSignature = New-KitRuntimeSignature ` -PublicHost $resolvedPublicHost ` @@ -967,11 +969,6 @@ $governanceRuntimeSignature = New-GovernanceRuntimeSignature ` -FileLibraryRoot $resolvedGovernanceFileLibraryRoot ` -A4InternalContextTokenFingerprint $a4InternalContextTokenFingerprint ` -Revision $resolvedDeployRevision -$kitManagerRuntimeSignature = New-KitManagerRuntimeSignature ` - -BindHost $resolvedHostNativeBindHost ` - -Port 8010 ` - -KitControlUrl $resolvedKitControlUrl ` - -Revision $resolvedDeployRevision $resolvedGovernanceApiBaseForDocker = if ($SkipGovernance) { '' } else { "http://host.docker.internal:$resolvedGovernancePort" } if (-not $SkipGovernance) { [Environment]::SetEnvironmentVariable('HOST_GOVERNANCE_API_BASE', $resolvedGovernanceApiBaseForDocker, 'Process') @@ -1387,6 +1384,26 @@ if ($volume.status -eq 'MISSING_KEY') { $fixActions++ } +# Kit control authority is resolved HERE, after the missing-key merge and the +# volume fix, because both can still change $resolvedEnvFile or add the +# KIT_CONTROL_URL key. Resolving it earlier meant a run that repaired the env +# file still launched the Kit Manager with the stale pre-merge value and then +# persisted a runtime signature describing the repaired state — a blocked +# runtime-control state that reads as configured on the next run. +# Assigned exactly once: test-deploy-governance-static.ps1 proves the single +# assignment and this ordering. +$resolvedKitControlUrl = if ($SkipKitManager) { + '' +} else { + Resolve-HostNativeKitControlUrl ` + -KitControlUrl (Get-DeployEnvValue -Name 'KIT_CONTROL_URL' -EnvFile $resolvedEnvFile -Default '').Trim() +} +$kitManagerRuntimeSignature = New-KitManagerRuntimeSignature ` + -BindHost $resolvedHostNativeBindHost ` + -Port 8010 ` + -KitControlUrl $resolvedKitControlUrl ` + -Revision $resolvedDeployRevision + # fix: 清 stale PID file foreach ($pidFile in Get-ChildItem -LiteralPath $RunDir -Filter '*.pid' -ErrorAction SilentlyContinue) { $name = [System.IO.Path]::GetFileNameWithoutExtension($pidFile.Name) diff --git a/scripts/tests/test-deploy-governance-static.ps1 b/scripts/tests/test-deploy-governance-static.ps1 index d19209f69..925cf2f1d 100644 --- a/scripts/tests/test-deploy-governance-static.ps1 +++ b/scripts/tests/test-deploy-governance-static.ps1 @@ -441,6 +441,34 @@ if ($kitBuildIndex -lt 0 -or $cadHardeningIndex -le $kitBuildIndex -or $envMerge throw 'CAD cache hardening must run after the Kit build gate and before later deployment phases' } +# The Phase 2 missing-key merge can BOTH append a missing KIT_CONTROL_URL and +# repoint $resolvedEnvFile from the .example to the real env file. Resolving the +# Kit control authority (or sealing it into the Kit Manager runtime signature) +# before that block leaves the current run starting the child with the stale +# pre-merge value and persisting a signature that claims the repaired state — +# a blocked runtime-control state that looks configured. +$kitControlResolveIndex = $deploy.IndexOf('$resolvedKitControlUrl = if ($SkipKitManager)') +$kitManagerSignatureIndex = $deploy.IndexOf('$kitManagerRuntimeSignature = New-KitManagerRuntimeSignature') +if ($kitControlResolveIndex -lt 0) { + throw 'deploy.ps1 must resolve the Kit control authority through the SkipKitManager-aware assignment' +} +if ($kitManagerSignatureIndex -lt 0) { + throw 'deploy.ps1 must build the Kit Manager runtime signature from the resolved control authority' +} +if ($kitControlResolveIndex -le $envMergeIndex) { + throw 'deploy.ps1 must resolve the Kit control authority AFTER the .env missing-key merge so a repaired KIT_CONTROL_URL takes effect in the same run' +} +if ($kitManagerSignatureIndex -le $envMergeIndex) { + throw 'deploy.ps1 must build the Kit Manager runtime signature AFTER the .env missing-key merge so it never persists the stale pre-merge control authority' +} +if ($kitManagerSignatureIndex -le $kitControlResolveIndex) { + throw 'deploy.ps1 must resolve the Kit control authority before sealing it into the Kit Manager runtime signature' +} +$kitManagerStartIndex = $deploy.IndexOf('Start-HostNativeKitManager -RepoRoot $RepoRoot -Port 8010 -KitControlUrl $resolvedKitControlUrl') +if ($kitManagerStartIndex -le $kitManagerSignatureIndex) { + throw 'deploy.ps1 must start the host-native Kit Manager with the post-merge control authority' +} + # Hybrid mode must not start a CONTAINERISED kit-manager-api. `compose up # coordinator viewer` used to pull it in through coordinator's depends_on, and # that service publishes 127.0.0.1:8010 - the same port deploy.ps1 Phase 4c-2 From 5839760cc6ec38b92160c0ea9248074b8976bee8 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:38:01 +0800 Subject: [PATCH 02/12] fix(verify): assert Kit control URL locality in the Deployment profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $expectedKitControlUrl was read verbatim from kit-manager-api.params.json and only string-compared against the /health payload's kit_control_url. The signature checks cover port and revision equality, which prove checkout identity and nothing about URL policy, and the local-address assertion was applied to host_native_bind_host and the conversion health host but never to this URL. A REMOTE origin agreed between a drifted signature and a drifted service therefore passed silently. Assert-DeploymentKitControlUrlIsLocal now runs beside the two existing asserts inside the same -not $PlanOnly guard. It deliberately re-implements the rule from Resolve-HostNativeKitControlUrl rather than importing it — the verifier is an independent layer, exactly as Assert-DeploymentHostNativeBindIsLocal re-implements Test-HostNativeLocalAddress. Empty stays allowed as the honest unconfigured state, localhost is accepted because the launcher canonicalises it through, and non-literal hosts are refused without DNS resolution so the verifier cannot be rebound. test-verify-all.ps1 gains a locality accept/reject matrix, subprocess rejection cases for a remote and a credentialed control URL, acceptance runs for 127.0.0.1 and localhost, an AST assertion that the verifier never calls the launcher resolver, and a paired case proving that a matching-but-remote health payload satisfies the identity comparison on its own. Refs #491 Co-Authored-By: Claude Fable 5 --- scripts/tests/test-verify-all.ps1 | 119 ++++++++++++++++++++++++++++++ scripts/verify-all.ps1 | 52 +++++++++++++ 2 files changed, 171 insertions(+) diff --git a/scripts/tests/test-verify-all.ps1 b/scripts/tests/test-verify-all.ps1 index 8134a8d86..6503d6877 100644 --- a/scripts/tests/test-verify-all.ps1 +++ b/scripts/tests/test-verify-all.ps1 @@ -221,6 +221,9 @@ try { Assert-True ($verifySource -match 'conversionRuntimeSignature\.revision -cne \$deploymentCheckoutRevision') 'deployment verifier rejects a conversion runtime from another revision' Assert-True ($verifySource -match 'kitManagerRuntimeSignature\.revision -cne \$deploymentCheckoutRevision') 'deployment verifier rejects a Kit Manager runtime from another revision' Assert-True ($verifySource -match 'kitControlUrl\)\.TrimEnd\(''\/''\)') 'deployment verifier compares the normalized child Kit control origin recorded by service settings' + Assert-True ($verifySource -match 'function Assert-DeploymentKitControlUrlIsLocal') 'deployment verifier defines an independent Kit control locality assertion' + Assert-True ($verifySource -match 'Assert-DeploymentKitControlUrlIsLocal -Url \$expectedKitControlUrl') 'deployment verifier applies the locality assertion to the expected Kit control URL' + Assert-True ($verifySource -notmatch 'host-native-launcher\.ps1') 'deployment verifier never dot-sources the launcher it verifies' $verifyTokens = $null $verifyParseErrors = $null @@ -327,9 +330,87 @@ try { } Assert-True ($redactedFailure -match [regex]::Escape('')) 'deployment HTTP failure uses the redacted display host' Assert-True (-not $redactedFailure.Contains($privateHost)) 'deployment HTTP failure never retains the private inventory host' + + # SEC-004 paired proof: a drifted signature and a drifted service can AGREE on + # a remote Kit control origin. The identity comparison is satisfied by that + # agreement, so only an independent locality policy rejects it. + $agreedRemoteControlUrl = 'http://192.0.2.51:49101' + $script:deploymentHttpFixture = @{ + StatusCode = 200 + Content = ('{{"status":"ok","runtime_mode":"hybrid-web-plane-host-native-kit","host_local_runtime_allowed":true,"kit_instance_id":"kit_local_001","kit_control_url":"{0}"}}' -f $agreedRemoteControlUrl) + ThrowMessage = '' + } + $agreedRemoteFailure = '' + try { + Test-DeploymentHttpEndpoint -Name 'kit manager health' -Uri 'http://127.0.0.1:8010/health' ` + -DisplayUri 'http://127.0.0.1:8010/health' -ExpectJson -ExpectedService '' ` + -ExpectedJsonProperties @{ + runtime_mode = 'hybrid-web-plane-host-native-kit' + host_local_runtime_allowed = $true + kit_instance_id = 'kit_local_001' + kit_control_url = $agreedRemoteControlUrl + } + } + catch { + $agreedRemoteFailure = $_.Exception.Message + } + Assert-Equal '' $agreedRemoteFailure 'a remote Kit control origin agreed by signature and service passes the identity comparison alone' Remove-Item Function:Invoke-WebRequest -Force Write-TestPass 'deployment HTTP JSON identity and redaction rejection matrix' + $localityFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Assert-DeploymentKitControlUrlIsLocal' + }, $true)) + Assert-Equal 1 $localityFunctions.Count 'deployment verifier exposes exactly one Kit control locality assertion' + # Independence is a property of the executable graph, not of the prose: the + # verifier may NAME the launcher rule it mirrors, but must never invoke it. + $launcherInvocations = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Resolve-HostNativeKitControlUrl' + }, $true)) + Assert-Equal 0 $launcherInvocations.Count 'deployment verifier re-implements the locality rule instead of calling the launcher it verifies' + . ([scriptblock]::Create($localityFunctions[0].Extent.Text)) + + $localityRejectCases = @( + @{ Name = 'remote literal address'; Url = $agreedRemoteControlUrl; Expected = 'loopback or an address assigned to this host' }, + @{ Name = 'remote DNS name'; Url = 'http://kit.example.invalid:49101'; Expected = 'loopback or an address assigned to this host' }, + @{ Name = 'https scheme'; Url = 'https://localhost:49101'; Expected = 'origin-only absolute HTTP URL' }, + @{ Name = 'credentials'; Url = 'http://user:pass@localhost:49101'; Expected = 'origin-only absolute HTTP URL' }, + @{ Name = 'path'; Url = 'http://localhost:49101/control'; Expected = 'origin-only absolute HTTP URL' }, + @{ Name = 'query'; Url = 'http://localhost:49101/?x=1'; Expected = 'origin-only absolute HTTP URL' }, + @{ Name = 'fragment'; Url = 'http://localhost:49101/#f'; Expected = 'origin-only absolute HTTP URL' }, + @{ Name = 'relative'; Url = 'localhost:49101'; Expected = 'origin-only absolute HTTP URL' } + ) + foreach ($localityCase in $localityRejectCases) { + $localityFailure = '' + try { + Assert-DeploymentKitControlUrlIsLocal -Url ([string]$localityCase.Url) + } + catch { + $localityFailure = $_.Exception.Message + } + Assert-True ($localityFailure -match [regex]::Escape([string]$localityCase.Expected)) "deployment Kit control locality rejects $($localityCase.Name)" + } + + # The empty string is the honest unconfigured/blocked state, and `localhost` + # MUST be accepted or the verifier contradicts the launcher, which + # canonicalises http://localhost:49101 through. + foreach ($acceptedControlUrl in @('', 'http://127.0.0.1:49101', 'http://localhost:49101', 'http://[::1]:49101')) { + $acceptedFailure = '' + try { + Assert-DeploymentKitControlUrlIsLocal -Url $acceptedControlUrl + } + catch { + $acceptedFailure = $_.Exception.Message + } + Assert-Equal '' $acceptedFailure "deployment Kit control locality accepts '$acceptedControlUrl'" + } + Remove-Item Function:Assert-DeploymentKitControlUrlIsLocal -Force + Write-TestPass 'deployment Kit control locality accept and reject matrix' + $executionInventoryPath = Join-Path $sandbox 'execution-target.local.json' $executionDeployRoot = if ($IsWindows) { '/tmp/ai-bim-verify-execution-deploy' } else { $deploymentRoot } $executionRuntimeDataRoot = if ($IsWindows) { @@ -429,6 +510,30 @@ try { ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $root 'kit-manager-api.params.json') -Encoding utf8 } + }, + # SEC-004: port and revision equality only prove checkout identity. A + # remote control origin agreed between a drifted signature and a drifted + # service used to pass, because locality was asserted for the host-native + # bind and the conversion health host but never for this URL. + @{ + Name = 'remote Kit control URL' + Expected = 'loopback or an address assigned to this host' + Mutate = { + param($root) + [pscustomobject]@{ kitControlUrl = 'http://192.0.2.51:49101'; port = 8010; revision = $deploymentRevision } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath (Join-Path $root 'kit-manager-api.params.json') -Encoding utf8 + } + }, + @{ + Name = 'non-origin Kit control URL' + Expected = 'origin-only absolute HTTP URL' + Mutate = { + param($root) + [pscustomobject]@{ kitControlUrl = 'http://user:pass@localhost:49101'; port = 8010; revision = $deploymentRevision } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath (Join-Path $root 'kit-manager-api.params.json') -Encoding utf8 + } } ) foreach ($signatureCase in $runtimeSignatureCases) { @@ -439,6 +544,20 @@ try { Assert-True ($executionResult.ExitCode -ne 0) "deployment execution rejects $($signatureCase.Name)" Assert-True ($executionResult.Output -match [string]$signatureCase.Expected) "deployment execution reports $($signatureCase.Name)" } + + # Local control origins must survive the new policy: the launcher + # canonicalises http://localhost:49101 through, so a verifier that rejected it + # would contradict the mechanism it verifies. These runs still fail on the + # unreachable health endpoints - they must not fail on locality. + foreach ($acceptedKitControlUrl in @('http://127.0.0.1:49101', 'http://localhost:49101')) { + Set-ValidDeploymentRuntimeSignatures -Root $runtimeSignatureRoot + [pscustomobject]@{ kitControlUrl = $acceptedKitControlUrl; port = 8010; revision = $deploymentRevision } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath (Join-Path $runtimeSignatureRoot 'kit-manager-api.params.json') -Encoding utf8 + $acceptedResult = Invoke-VerificationExecution -RepoRoot $deploymentRoot -AdditionalArguments $executionArguments + Assert-True ($acceptedResult.Output -notmatch 'loopback or an address assigned to this host') "deployment execution accepts local Kit control URL '$acceptedKitControlUrl'" + Assert-True ($acceptedResult.Output -notmatch 'origin-only absolute HTTP URL') "deployment execution accepts the origin shape of '$acceptedKitControlUrl'" + } Set-ValidDeploymentRuntimeSignatures -Root $runtimeSignatureRoot Write-TestPass 'deployment runtime signature rejection matrix' diff --git a/scripts/verify-all.ps1 b/scripts/verify-all.ps1 index 43d32af58..a15778c19 100644 --- a/scripts/verify-all.ps1 +++ b/scripts/verify-all.ps1 @@ -227,6 +227,57 @@ function Assert-DeploymentHostNativeBindIsLocal { } } +function Assert-DeploymentKitControlUrlIsLocal { + # SEC-004: the recorded Kit control origin was compared against the /health + # payload and nothing else. Port and revision equality only prove checkout + # identity, so a REMOTE origin agreed between a drifted signature and a + # drifted service passed silently. The canonical launcher refuses remote + # targets, but this verifier is deliberately an independent layer: it + # re-implements the launcher's rule (Resolve-HostNativeKitControlUrl) rather + # than importing the mechanism it is supposed to check, exactly as + # Assert-DeploymentHostNativeBindIsLocal re-implements + # Test-HostNativeLocalAddress. + [CmdletBinding()] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Url) + + # Empty is the honest unconfigured/blocked runtime-control state. + if ([string]::IsNullOrWhiteSpace($Url)) { return } + + $controlUri = $null + if (-not [uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$controlUri) -or + $controlUri.Scheme -ne 'http' -or + [string]::IsNullOrWhiteSpace($controlUri.Host) -or + -not [string]::IsNullOrWhiteSpace($controlUri.UserInfo) -or + -not [string]::IsNullOrWhiteSpace($controlUri.Query) -or + -not [string]::IsNullOrWhiteSpace($controlUri.Fragment) -or + -not ($controlUri.AbsolutePath -eq '' -or $controlUri.AbsolutePath -eq '/')) { + throw 'Deployment Kit control URL must be an origin-only absolute HTTP URL without credentials, path, query, or fragment.' + } + + $normalizedHost = $controlUri.Host.Trim().Trim([char[]]'[]').ToLowerInvariant() + # localhost MUST be accepted: the launcher canonicalises + # http://localhost:49101 through, so rejecting it here would make the + # verifier contradict the mechanism it verifies. + if ($normalizedHost -eq 'localhost') { return } + + $controlAddress = $null + if (-not [Net.IPAddress]::TryParse($normalizedHost, [ref]$controlAddress)) { + # No DNS resolution: a DNS answer is not proof that the control authority + # is bound to this host, and resolving here would accept rebinding. + throw 'Deployment Kit control URL host must be loopback or an address assigned to this host.' + } + if ([Net.IPAddress]::IsLoopback($controlAddress)) { return } + + $localAddresses = @( + [Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | + ForEach-Object { $_.GetIPProperties().UnicastAddresses } | + ForEach-Object { $_.Address } + ) + if (@($localAddresses | Where-Object { $_.Equals($controlAddress) }).Count -eq 0) { + throw 'Deployment Kit control URL host must be loopback or an address assigned to this host.' + } +} + if ($VerifyProfile -eq 'Deployment' -and ($StreamingOnly -or $TsOnly -or $PyOnly)) { throw 'Deployment profile does not accept StreamingOnly, TsOnly, or PyOnly filters.' } @@ -351,6 +402,7 @@ if ($VerifyProfile -eq 'Deployment') { if (-not $PlanOnly) { Assert-DeploymentHostNativeBindIsLocal -HostName ([string]$deploymentTarget.host_native_bind_host) Assert-DeploymentHostNativeBindIsLocal -HostName $conversionHealthHost + Assert-DeploymentKitControlUrlIsLocal -Url $expectedKitControlUrl } $Targets += @{ From 1099e836bc03e670c425caeff2f3237ebfe8b61e Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:43:40 +0800 Subject: [PATCH 03/12] fix(launcher): contain descendants in Stop-HostNativeProcessTreeAndWait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill($true), WaitForExit and HasExited all describe the SAME Process object. Microsoft documents that they can report completion while descendants are still running, and the old catch additionally swallowed the tree-kill exception whenever the parent had already exited — precisely the case where orphaned grandchildren survive. The CAD hardener and the Kit Manager import probe use this helper to prove a timed-out tree is gone before releasing the trust boundary they hold, so the postcondition was false where it mattered most. Second defect in the same helper: Process.Kill([bool]) does not exist on .NET Framework, so under Windows PowerShell 5.1 the tree kill ALWAYS threw into that catch and the helper silently degraded to parent-only termination. The helper now snapshots the descendant PID set through Get-PlatformChildProcessIds before terminating (afterwards the parent/child links are gone and orphans are re-parented), guards the tree kill behind an overload probe with the enumerated-PID fallback Stop-HostNativeService already uses, and waits for the parent AND every snapshotted descendant inside one bounded budget. Anything still alive throws instead of reporting success. Descendant liveness is judged on process identity, not the bare PID, so a recycled PID reads as "our descendant is gone" rather than failing a caller closed on an unrelated process. test-host-native-launcher.ps1 gains a dynamic hung-fixture case proving both recorded PIDs exited inside the bounded window, a negative case with an injected unkillable descendant proving the helper fails closed, and source-shape assertions pinning the overload guard. This commit also registers the bundle's single open ledger entry `mechanism-hardening-2`, covering the three verification-mechanism paths this pull request changes (#490 deploy.ps1, #491 verify-all.ps1, #489 host-native-launcher.ps1). One entry, one canonical Linux rebuild at fixpoint, instead of three serialised debts for one hardening round. Refs #489 Co-Authored-By: Claude Fable 5 --- .../self-referential-bootstrap/README.md | 38 +++++ .../verification.txt | 30 ++++ scripts/lib/host-native-launcher.ps1 | 98 ++++++++++++- .../self-referential-bootstrap-ledger.json | 39 +++++ scripts/tests/test-host-native-launcher.ps1 | 138 ++++++++++++++++++ 5 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md create mode 100644 docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md new file mode 100644 index 000000000..0e4457ad5 --- /dev/null +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md @@ -0,0 +1,38 @@ +> Document nature: **working note**. This file is bootstrap evidence, not an authoritative runtime, API, or deployment specification. + +# Mechanism hardening 2 bootstrap + +- `stack_kind=self_referential_bootstrap` +- Pull request: see the ledger entry `mechanism-hardening-2` (`pr` field is the binding record) +- Baseline: freshly fetched `origin/main` at `472192386f8402cf19a29005daf25556d26f222c` +- This is isolated branch bootstrap evidence. It is not canonical post-change evidence and does not claim full-system E2E completion. + +## Scope + +Three post-merge findings from the PR #484 Codex tri-adversarial ship-gate, landed together because each one edits a classified verification-mechanism path: + +| Issue | Finding | Mechanism path | +|---|---|---| +| #490 | L1-COR-001 — Kit control URL fixed before the `.env` missing-key merge | `scripts/deploy.ps1` | +| #491 | SEC-004 — Deployment profile never applied locality to the Kit control URL | `scripts/verify-all.ps1` | +| #489 | L1-COR-004 — process-tree terminator proved only the parent exited | `scripts/lib/host-native-launcher.ps1` | + +They share one ledger entry deliberately. The debt gate admits one open entry at a time, and every entry owes a full canonical Linux rebuild plus deployment verification to close. Splitting these three into separate pull requests would serialise three rebuilds for one coherent hardening round. + +## Why this branch cannot produce canonical post-change evidence + +The canonical deployment transport rebuilds the Linux test target only from freshly fetched `origin/main` and refuses an unmerged revision. All three changes live inside that transport: the deploy entrypoint that resolves runtime identity, the aggregate verifier that adjudicates the deployed runtime, and the shared launcher primitive both rely on to prove a terminated process tree is gone. A pre-merge run against `origin/main` therefore exercises the unchanged mechanism, and the changed mechanism has no mainline to run on until this merges. + +## What this branch did verify + +Local mechanism suites on the branch head, on Windows with PowerShell 7.5.4. The recorded results are in `verification.txt`. + +## Limits + +- No canonical Linux rebuild and no canonical deployment verification were executed for this bundle. Both are recorded in the entry's verification contract and are owed at fixpoint. +- The Linux leg of the verifier (`pwsh scripts/verify-all.ps1 -Profile Deployment -PlanOnly` on the canonical target) was not executed from this workstation; only the Windows leg was. +- Full-system browser, Kit first-frame, stage, and DataChannel E2E are not claimed. + +## Fixpoint obligation + +After this pull request merges, rebuild the canonical Linux test target from freshly fetched `origin/main`, rerun the entry's verification contract in full, record the merged mechanism commit and the canonical evidence under `docs/evidence/mechanism-hardening-2/fixpoint/`, and close the ledger entry with its attestation. diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt new file mode 100644 index 000000000..0dc1ed1b2 --- /dev/null +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt @@ -0,0 +1,30 @@ +document_nature=working_note_bootstrap_evidence_not_runtime_spec +stack_kind=self_referential_bootstrap +ledger_entry=mechanism-hardening-2 +base_commit=472192386f8402cf19a29005daf25556d26f222c +host=windows pwsh=7.5.4 +gitnexus=NOT_APPLICABLE (the GitNexus index carries no PowerShell symbols; `impact Stop-HostNativeProcessTreeAndWait` and `impact Resolve-HostNativeKitControlUrl` both returned target-not-found, so callers were established by repository-wide grep instead) +PASS test-deploy-governance-static +PASS test-verify-all +PASS test-host-native-launcher +PASS test-host-native-child-launch +PASS test-deploy-dryrun +PASS test-deploy-env-fallback +PASS test-rebuild-test-deploy +PASS test-platform-adapter +PASS test-preflight-ports +PASS test-stop-all-single-pid +PASS test-kit-log-probe +PASS test-deploy-target-registry +PASS test-remote-deploy-transport +PASS test-self-referential-bootstrap +PASS test-agent-governance-check +PASS invoke-powershell-static +NOTE test-deploy-dryrun and test-deploy-env-fallback pass on this branch but are absent from the immutable command map in scripts/tests/test-self-referential-bootstrap.ps1, which is itself a gated file; they are therefore recorded here as evidence but are not verification-contract command ids +PASS deploy-dry-run-operator-path (scripts/deploy.ps1 -DryRun exits 0 and leaves the working tree clean) +PASS verify-all-deployment-plan-only-windows (scripts/verify-all.ps1 -Profile Deployment -PlanOnly exits 0) +NOT_RUN verify-all-deployment-plan-only-linux (no canonical Linux session was opened for this bundle) +NOT_RUN canonical-linux-rebuild (owed at fixpoint; no baseline rebuild was executed for this bundle) +NOT_RUN canonical-linux-deployment-verify (owed at fixpoint) +LIMIT canonical-post-change-verification=pending merge and mainline fixpoint rebuild +LIMIT full-system-e2e=not claimed diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 91ccf5967..b183225a1 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -523,24 +523,118 @@ function Start-HostNativeGovernance { } function Stop-HostNativeProcessTreeAndWait { + # Bounded, fail-closed process-tree terminator. The CAD hardener and the Kit + # Manager import probe rely on it to PROVE that a timed-out tree is gone + # before releasing the trust boundary they hold. + # + # Two defects made that proof false (#489 L1-COR-004): + # 1. Kill($true), WaitForExit and HasExited all describe the SAME Process + # object. Microsoft documents that they can report completion while + # descendants are still running, and the old catch additionally swallowed + # the tree-kill exception whenever the parent had already exited - the + # exact case where orphaned grandchildren survive. + # 2. Process.Kill([bool]) does not exist on .NET Framework, so under Windows + # PowerShell 5.1 the tree kill ALWAYS threw into that catch and the helper + # silently degraded to "parent only". + # + # Fix: snapshot the descendant PID set BEFORE terminating (afterwards the + # parent/child links are gone and orphans are re-parented), guard the tree + # kill behind an overload probe with the enumerated-PID fallback used by + # Stop-HostNativeService, then wait for the parent AND every snapshotted + # descendant inside one bounded budget. Anything still alive throws. + # + # Descendant liveness is judged on process IDENTITY, not the bare PID, so a + # recycled PID is correctly read as "our descendant is gone" instead of + # failing a caller closed on an unrelated process. [CmdletBinding()] param( [Parameter(Mandatory = $true)][System.Diagnostics.Process] $Process, - [ValidateRange(1, 60000)][int] $TimeoutMs = 5000 + [ValidateRange(1, 60000)][int] $TimeoutMs = 5000, + # Injectable so the containment postcondition is testable without an + # actually unkillable process. + [scriptblock] $ChildPidLookup = { + param($parentId) + @(Get-PlatformChildProcessIds -ParentProcessId ([int]$parentId)) + }, + [scriptblock] $IdentityProbeFn = { + param($procId) + Get-PlatformProcessIdentity -ProcessId ([int]$procId) + }, + [scriptblock] $StopProcessFn = { + param($procId) + Stop-Process -Id ([int]$procId) -Force -ErrorAction SilentlyContinue + }, + [scriptblock] $SleepFn = { + param($milliseconds) + Start-Sleep -Milliseconds ([int]$milliseconds) + } ) if ($Process.HasExited) { return } + $parentProcessId = [int]$Process.Id + + $descendantIds = @() + $descendantIdentities = @{} + $pending = @($parentProcessId) + while ($pending.Count -gt 0) { + $current = [int]$pending[0] + $pending = @($pending | Select-Object -Skip 1) + foreach ($childId in @(& $ChildPidLookup $current)) { + $childProcessId = [int]$childId + if ($childProcessId -eq $parentProcessId) { continue } + if ($descendantIds -contains $childProcessId) { continue } + $descendantIds += $childProcessId + $descendantIdentities[$childProcessId] = (& $IdentityProbeFn $childProcessId) + $pending += $childProcessId + } + } + + $budget = [System.Diagnostics.Stopwatch]::StartNew() + $supportsTreeKill = $null -ne [System.Diagnostics.Process].GetMethod('Kill', [type[]]@([bool])) try { - $Process.Kill($true) + if ($supportsTreeKill) { + $Process.Kill($true) + } + else { + # Windows PowerShell 5.1 / .NET Framework: no tree overload. Kill the + # snapshot deepest-first, then the parent - the same enumerated-PID + # shape Stop-HostNativeService already uses. + for ($i = $descendantIds.Count - 1; $i -ge 0; $i--) { + & $StopProcessFn ([int]$descendantIds[$i]) + } + $Process.Kill() + } } catch { + # An already-exited PARENT is the only tolerated failure, and it is not a + # licence to stop: its descendants outlive it, so terminate the snapshot + # explicitly instead of swallowing the exception as before. if (-not $Process.HasExited) { throw "Process tree termination failed for PID $($Process.Id): $($_.Exception.Message)" } + for ($i = $descendantIds.Count - 1; $i -ge 0; $i--) { + & $StopProcessFn ([int]$descendantIds[$i]) + } } if (-not $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { throw "Process tree for PID $($Process.Id) did not terminate within $TimeoutMs ms." } + + $survivors = @($descendantIds) + while ($survivors.Count -gt 0) { + $survivors = @($survivors | Where-Object { + $descendantProcessId = [int]$_ + Test-PlatformProcessIdentityMatch ` + -Reference $descendantIdentities[$descendantProcessId] ` + -Current (& $IdentityProbeFn $descendantProcessId) + }) + if ($survivors.Count -eq 0) { break } + if ($budget.ElapsedMilliseconds -ge $TimeoutMs) { break } + & $SleepFn 50 + } + if ($survivors.Count -gt 0) { + throw "Process tree for PID $($Process.Id) left descendant PID(s) $($survivors -join ', ') running after $TimeoutMs ms." + } } # R5(2026-07-10 衛生輪 C3):kit-manager-api 納入 golden path——hybrid 模式下 coordinator diff --git a/scripts/self-referential-bootstrap-ledger.json b/scripts/self-referential-bootstrap-ledger.json index 07a33933c..d45d19dd6 100644 --- a/scripts/self-referential-bootstrap-ledger.json +++ b/scripts/self-referential-bootstrap-ledger.json @@ -146,6 +146,45 @@ "docs/evidence/linux-test-deploy-verifier-hardening/fixpoint/summary.md" ] } + }, + { + "id": "mechanism-hardening-2", + "status": "open", + "pr": 513, + "opened_at": "2026-08-12T02:38:49Z", + "reason": "the canonical Linux transport rebuilds only from freshly fetched origin/main and refuses an unmerged revision, so this branch cannot exercise its changed deploy resolver, its Deployment-profile locality assertion, or its process-tree terminator as canonical post-change evidence before merge; only a mainline fixpoint rebuild can produce that proof", + "verification_mechanism_paths": [ + "scripts/deploy.ps1", + "scripts/lib/host-native-launcher.ps1", + "scripts/self-referential-bootstrap-ledger.json", + "scripts/verify-all.ps1" + ], + "verification_contract": { + "id": "mechanism-hardening-2/v1", + "command_ids": [ + "test-deploy-governance-static", + "test-verify-all", + "test-host-native-launcher", + "test-host-native-child-launch", + "test-platform-adapter", + "test-preflight-ports", + "test-kit-log-probe", + "test-deploy-target-registry", + "test-remote-deploy-transport", + "test-rebuild-test-deploy", + "test-self-referential-bootstrap", + "test-agent-governance-check", + "invoke-powershell-static", + "canonical-linux-rebuild", + "canonical-linux-deployment-verify" + ], + "contract_sha256": "881fac8337efca52cb16e753cc016ee3219297180bfeec092406cad5ff7b2bc6" + }, + "bootstrap_evidence_refs": [ + "docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md", + "docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt" + ], + "fixpoint": null } ] } diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index 9fbfd9424..ec538f236 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -660,4 +660,142 @@ foreach ($controlCase in $rejectedControlUrls) { } Write-TestPass 'Kit control URL authority shape matrix' +# --------------------------------------------------------------------------- +# Process-tree containment (#489 L1-COR-004). +# Kill($true) + WaitForExit + HasExited only ever describe the SAME Process +# object, and the old catch swallowed the tree-kill exception whenever the +# parent had already exited - so the helper reported success while +# grandchildren kept running. The postcondition must cover every descendant. +# --------------------------------------------------------------------------- +Assert-True ($moduleContent -match "GetMethod\('Kill'") 'bounded process-tree terminator probes the Kill(bool) overload before using it' +Assert-True ($moduleContent -match 'Get-PlatformChildProcessIds -ParentProcessId') 'bounded process-tree terminator enumerates descendants through the platform adapter' +$overloadProbeIndex = $moduleContent.IndexOf("GetMethod('Kill'") +$treeKillIndex = $moduleContent.IndexOf('$Process.Kill($true)') +Assert-True ($overloadProbeIndex -ge 0 -and $treeKillIndex -gt $overloadProbeIndex) 'bounded process-tree terminator guards the tree kill behind the overload probe' + +$treeSandbox = New-TestSandbox -Prefix 'hn-tree-terminator' +try { + $fixturePython = Resolve-PlatformSystemPython + if ([string]::IsNullOrWhiteSpace($fixturePython)) { + throw 'process-tree containment regressions require a working Python 3.11+ interpreter' + } + + function Start-TreeFixtureProcess { + param( + [Parameter(Mandatory = $true)][string] $PythonExe, + [Parameter(Mandatory = $true)][string] $ScriptPath, + [Parameter(Mandatory = $true)][string] $PidPath + ) + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $PythonExe + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + [void]$startInfo.ArgumentList.Add($ScriptPath) + [void]$startInfo.ArgumentList.Add($PidPath) + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw 'process-tree fixture did not start' } + for ($attempt = 0; $attempt -lt 200; $attempt++) { + if (Test-Path -LiteralPath $PidPath -PathType Leaf) { break } + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path -LiteralPath $PidPath -PathType Leaf)) { + throw 'process-tree fixture never recorded its PIDs' + } + return $process + } + + # Case 1: a real parent with a real grandchild-capable child. Both PIDs must + # be gone by the time the helper returns, inside the bounded window. + $treeFixture = Join-Path $treeSandbox 'tree-fixture.py' + $treePidFile = Join-Path $treeSandbox 'tree-pids.json' + $treeSource = @' +import json +import os +import pathlib +import subprocess +import sys +import time + +child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) +pathlib.Path(sys.argv[1]).write_text( + json.dumps([os.getpid(), child.pid]), encoding="utf-8" +) +time.sleep(120) +'@ + [System.IO.File]::WriteAllText($treeFixture, $treeSource) + $treeProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $treeFixture -PidPath $treePidFile + $treePids = @(Get-Content -Raw -LiteralPath $treePidFile | ConvertFrom-Json) + Assert-Equal 2 $treePids.Count 'process-tree fixture records exactly its parent and child PIDs' + $treeStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Stop-HostNativeProcessTreeAndWait -Process $treeProcess -TimeoutMs 5000 + } + finally { + $treeStopwatch.Stop() + } + Assert-True ($treeStopwatch.Elapsed.TotalSeconds -lt 15) 'process-tree terminator returns inside the bounded cleanup window' + foreach ($treePid in $treePids) { + Assert-True ($null -eq (Get-PlatformProcessIdentity -ProcessId ([int]$treePid))) "process-tree terminator proves PID $treePid exited" + } + Write-TestPass 'process-tree terminator waits for every descendant, not only the parent' + + # Case 2: a descendant that cannot be killed must FAIL CLOSED. The old + # implementation reported success here because it never looked past the + # parent object. + $survivorFixture = Join-Path $treeSandbox 'survivor-fixture.py' + $survivorPidFile = Join-Path $treeSandbox 'survivor-pids.json' + $survivorSource = @' +import json +import os +import pathlib +import sys +import time + +pathlib.Path(sys.argv[1]).write_text(json.dumps([os.getpid()]), encoding="utf-8") +time.sleep(120) +'@ + [System.IO.File]::WriteAllText($survivorFixture, $survivorSource) + $survivorProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $survivorFixture -PidPath $survivorPidFile + $unkillableDescendantId = 424242 + $survivorIdentity = [pscustomobject]@{ + ProcessId = $unkillableDescendantId + BirthToken = 'fixture-birth-token' + ExecutablePath = '' + CommandLine = '' + } + $survivorFailure = '' + $survivorStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Stop-HostNativeProcessTreeAndWait -Process $survivorProcess -TimeoutMs 1000 ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -eq $unkillableDescendantId) { return @() } + return @($unkillableDescendantId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -eq $unkillableDescendantId) { return $survivorIdentity } + return $null + }.GetNewClosure() ` + -StopProcessFn { param($procId) } | Out-Null + } + catch { + $survivorFailure = $_.Exception.Message + } + finally { + $survivorStopwatch.Stop() + if (-not $survivorProcess.HasExited) { + $survivorProcess.Kill() + [void]$survivorProcess.WaitForExit(5000) + } + } + Assert-True ($survivorFailure -match "left descendant PID\(s\) $unkillableDescendantId running") 'process-tree terminator fails closed when a snapshotted descendant survives' + Assert-True ($survivorStopwatch.Elapsed.TotalSeconds -lt 15) 'process-tree terminator bounds the descendant wait before failing closed' + Write-TestPass 'process-tree terminator fails closed on a surviving descendant' +} +finally { + Remove-TestSandbox -Path $treeSandbox +} + Write-Host "`n=== test-host-native-launcher.ps1: ALL PASSED ===" -ForegroundColor Green From 110c657fd620e3bdbac4379ac716da99d37848b9 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:04:32 +0800 Subject: [PATCH 04/12] fix(launcher): prove containment for exited parents and legacy fallbacks The PR #513 Codex tri-adversarial ship-gate returned NO-SHIP on four findings against Stop-HostNativeProcessTreeAndWait, and four PR review threads named the same defects. L1-COR-001 (high): `if ($Process.HasExited) { return }` ran before the descendant snapshot, so a parent that lost the race between the caller's liveness decision and this helper took the entire sweep with it while its descendants kept running. Neither OS cascades termination, so an exited parent now excuses the parent kill/wait only - the snapshot, the termination and the survivor proof still run. L1-COR-002: both fallback loops stopped snapshotted descendants by bare PID. A PID recycled between enumeration and the stop meant terminating an unrelated host process while the survivor poll still read "our descendant is gone". Every stop is now identity-revalidated immediately before it fires, and a changed incarnation is treated as already gone. L1-SEC-002: one fixed snapshot is not containment - a snapshotted process can spawn another child before it dies, and that child was in neither the stop list nor the success check. Containment is now a bounded fixed point that re-enumerates, terminates and verifies until a pass finds nothing new and nothing alive, and fails closed at the deadline. Re-enumeration expands only from roots that are still the incarnation we recorded; the parent stays a root because this call holds its Process handle. L1-TG-003: the tree-kill capability decision is injectable, so the .NET Framework / Windows PowerShell 5.1 fallback is driven as behaviour from a PowerShell 7 run instead of by source-string order alone. Six behavioural cases cover it: an already-exited parent with a real orphaned descendant, the same shape failing closed, an identity-gated stop on a recycled PID, a forced no-Kill(bool) fallback over a real three-level chain (deepest-first, parent terminated, every PID proven gone), a post-snapshot spawn, and the fallback's own fail-closed path. Two more findings from the same review round: - `deploy.ps1 -DryRun` stopped adjudicating KIT_CONTROL_URL when the authoritative resolution moved after the Phase 2 missing-key merge. That merge only appends an absent key with a default and can never repair an existing value, so an unusable authority is now a Phase 1 hard fail, reported without echoing the URL, with the authoritative post-merge resolution left where it is. - scripts/tests/test-host-native-launcher.ps1 ran in no workflow, so these regressions gated nothing. It now runs in the required rebuild-test-deploy job, PowerShell 7 only: the dynamic fixtures use ProcessStartInfo.ArgumentList, which .NET Framework does not have. Refs #489. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 + scripts/deploy.ps1 | 19 ++ scripts/lib/host-native-launcher.ps1 | 201 +++++++++---- .../self-referential-bootstrap-ledger.json | 2 + scripts/tests/test-deploy-dryrun.ps1 | 33 +++ scripts/tests/test-host-native-launcher.ps1 | 269 ++++++++++++++++++ scripts/verification-manifest.json | 5 +- 7 files changed, 484 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48b8fcd71..1b2ccf57d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -740,6 +740,15 @@ jobs: shell: pwsh run: pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/tests/test-verify-all.ps1 + # scripts/lib/host-native-launcher.ps1 is classified into this job, but no + # workflow ever ran its suite: the process-tree containment regressions + # were green only in manually recorded evidence, so a broken terminator + # could merge. PowerShell 7 only - the dynamic fixtures use + # ProcessStartInfo.ArgumentList, which .NET Framework does not have. + - name: Run host-native launcher contract tests (PowerShell 7) + shell: pwsh + run: pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/tests/test-host-native-launcher.ps1 + secret-pattern-scan: name: secret pattern scan needs: changes diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 792c35203..38347e4fe 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -1103,6 +1103,25 @@ if (-not (Test-PlatformServiceLingerEnabled)) { $hardFails += 'user_lingering_disabled' Write-DeployTag -Tag 'fail' -Message "host-native services would not survive logout: lingering is disabled for this account. Fix once with: loginctl enable-linger $(& id -un 2>`$null)" -LogPath $LogPath | Out-Null } +# Moving the AUTHORITATIVE Kit control resolution after the Phase 2 missing-key +# merge (below) also moved it past this dry-run exit, so the required preflight +# stopped reporting a malformed or non-local KIT_CONTROL_URL at all and the real +# deploy only discovered it after Phase 2 had already modified the environment. +# The merge can only APPEND an absent key with a default; it never rewrites an +# existing value, so an existing unusable authority is unfixable and belongs in +# Phase 1 hard fails. Validation only - nothing is assigned here, and the single +# authoritative assignment stays after the merge where a repaired file still +# takes effect. Resolve-HostNativeKitControlUrl's messages never echo the URL. +if (-not $SkipKitManager) { + try { + Resolve-HostNativeKitControlUrl ` + -KitControlUrl (Get-DeployEnvValue -Name 'KIT_CONTROL_URL' -EnvFile $resolvedEnvFile -Default '').Trim() | Out-Null + } + catch { + $hardFails += 'kit_control_url_unusable' + Write-DeployTag -Tag 'fail' -Message "KIT_CONTROL_URL in $resolvedEnvFile is not a usable Kit control authority: $($_.Exception.Message)" -LogPath $LogPath | Out-Null + } +} if ($DryRun) { Write-DeployHeader -Title 'Phase 2: Auto-fix (safe actions)' if ($hardFails.Count -gt 0) { diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index b183225a1..9901088df 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -537,15 +537,29 @@ function Stop-HostNativeProcessTreeAndWait { # PowerShell 5.1 the tree kill ALWAYS threw into that catch and the helper # silently degraded to "parent only". # + # 3. An `if ($Process.HasExited) { return }` pre-entry guard is not + # containment either (#513 gate L1-COR-001). The parent can lose the race + # between the caller's liveness decision and this helper, and NEITHER OS + # cascades termination, so its descendants keep running. An exited parent + # excuses the parent kill/wait only - never the sweep or the proof. + # 4. Stopping a snapshotted descendant by bare PID can terminate an unrelated + # process that inherited a recycled PID (#513 gate L1-COR-002), and the + # survivor poll would still read "our descendant is gone". + # 5. One fixed snapshot is not containment (#513 gate L1-SEC-002): a + # snapshotted process can spawn another child before it dies, and that + # child appears in neither the stop list nor the success check. + # # Fix: snapshot the descendant PID set BEFORE terminating (afterwards the # parent/child links are gone and orphans are re-parented), guard the tree # kill behind an overload probe with the enumerated-PID fallback used by - # Stop-HostNativeService, then wait for the parent AND every snapshotted - # descendant inside one bounded budget. Anything still alive throws. + # Stop-HostNativeService, then drive containment as a bounded FIXED POINT - + # re-enumerate, terminate what is still ours, verify - until a pass finds + # nothing new and nothing alive. Anything still alive at the deadline throws. # - # Descendant liveness is judged on process IDENTITY, not the bare PID, so a - # recycled PID is correctly read as "our descendant is gone" instead of - # failing a caller closed on an unrelated process. + # Descendant liveness is judged on process IDENTITY, not the bare PID, both + # before each stop and in the survivor proof, so a recycled PID is correctly + # read as "our descendant is gone" instead of killing an unrelated process or + # failing a caller closed on one. [CmdletBinding()] param( [Parameter(Mandatory = $true)][System.Diagnostics.Process] $Process, @@ -567,68 +581,151 @@ function Stop-HostNativeProcessTreeAndWait { [scriptblock] $SleepFn = { param($milliseconds) Start-Sleep -Milliseconds ([int]$milliseconds) + }, + # Injectable so the .NET Framework / Windows PowerShell 5.1 branch is + # reachable as BEHAVIOUR from a PowerShell 7 run instead of being pinned + # by source strings alone (#513 gate L1-TG-003). Windows PowerShell 5.1 + # compatibility is an explicit purpose of this helper, and that branch + # carries the two races above. + [scriptblock] $TreeKillCapabilityProbeFn = { + $null -ne [System.Diagnostics.Process].GetMethod('Kill', [type[]]@([bool])) } ) - if ($Process.HasExited) { return } + # Nested on purpose: scripts/tests/test-deploy-governance-static.ps1 extracts + # this function's AST and dot-sources it ALONE, so every helper it needs has + # to travel inside its own extent. + function Update-DescendantSnapshot { + # Breadth-first walk below every root. Returns the PIDs discovered on THIS + # pass in parent-before-child order (so reverse iteration is deepest-first) + # and records an identity for each one. + param( + # Expanded from. Callers pass only PIDs that are still the incarnation + # they recorded, so a recycled PID can never contribute an unrelated + # process's children to the containment set. + [Parameter(Mandatory = $true)][AllowEmptyCollection()][int[]] $RootProcessIds, + # Already recorded; seeds the visited set so they are never rediscovered + # or re-probed, even when they are no longer expanded from. + [Parameter(Mandatory = $true)][AllowEmptyCollection()][int[]] $KnownProcessIds, + [Parameter(Mandatory = $true)][hashtable] $Identities, + [Parameter(Mandatory = $true)][scriptblock] $LookupFn, + [Parameter(Mandatory = $true)][scriptblock] $ProbeFn + ) + $discovered = @() + $visited = @{} + foreach ($knownId in $KnownProcessIds) { $visited[[int]$knownId] = $true } + foreach ($rootId in $RootProcessIds) { $visited[[int]$rootId] = $true } + $pending = @($RootProcessIds) + while ($pending.Count -gt 0) { + $current = [int]$pending[0] + $pending = @($pending | Select-Object -Skip 1) + foreach ($childId in @(& $LookupFn $current)) { + $childProcessId = [int]$childId + if ($visited.ContainsKey($childProcessId)) { continue } + $visited[$childProcessId] = $true + $pending += $childProcessId + $discovered += $childProcessId + $Identities[$childProcessId] = (& $ProbeFn $childProcessId) + } + } + # Emitted flat on purpose: every call site re-collects with @(), which is + # what keeps the empty and single-descendant cases arrays. + return $discovered + } + + function Stop-DescendantSnapshot { + # Deepest-first termination, identity-revalidated immediately before each + # stop. A PID whose incarnation changed - or that we never got an identity + # for - is not ours to kill and is already gone for our purposes. + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][int[]] $DescendantIds, + [Parameter(Mandatory = $true)][hashtable] $Identities, + [Parameter(Mandatory = $true)][scriptblock] $ProbeFn, + [Parameter(Mandatory = $true)][scriptblock] $StopFn + ) + for ($i = $DescendantIds.Count - 1; $i -ge 0; $i--) { + $descendantProcessId = [int]$DescendantIds[$i] + if (-not (Test-PlatformProcessIdentityMatch ` + -Reference $Identities[$descendantProcessId] ` + -Current (& $ProbeFn $descendantProcessId))) { + continue + } + $null = & $StopFn $descendantProcessId + } + } + $parentProcessId = [int]$Process.Id + $parentAlreadyExited = $Process.HasExited - $descendantIds = @() $descendantIdentities = @{} - $pending = @($parentProcessId) - while ($pending.Count -gt 0) { - $current = [int]$pending[0] - $pending = @($pending | Select-Object -Skip 1) - foreach ($childId in @(& $ChildPidLookup $current)) { - $childProcessId = [int]$childId - if ($childProcessId -eq $parentProcessId) { continue } - if ($descendantIds -contains $childProcessId) { continue } - $descendantIds += $childProcessId - $descendantIdentities[$childProcessId] = (& $IdentityProbeFn $childProcessId) - $pending += $childProcessId - } - } + $descendantIds = @(Update-DescendantSnapshot ` + -RootProcessIds @($parentProcessId) ` + -KnownProcessIds @() ` + -Identities $descendantIdentities ` + -LookupFn $ChildPidLookup ` + -ProbeFn $IdentityProbeFn) $budget = [System.Diagnostics.Stopwatch]::StartNew() - $supportsTreeKill = $null -ne [System.Diagnostics.Process].GetMethod('Kill', [type[]]@([bool])) - try { - if ($supportsTreeKill) { - $Process.Kill($true) - } - else { - # Windows PowerShell 5.1 / .NET Framework: no tree overload. Kill the - # snapshot deepest-first, then the parent - the same enumerated-PID - # shape Stop-HostNativeService already uses. - for ($i = $descendantIds.Count - 1; $i -ge 0; $i--) { - & $StopProcessFn ([int]$descendantIds[$i]) + $supportsTreeKill = [bool](& $TreeKillCapabilityProbeFn) + if (-not $parentAlreadyExited) { + try { + if ($supportsTreeKill) { + $Process.Kill($true) + } + else { + # Windows PowerShell 5.1 / .NET Framework: no tree overload. Kill the + # snapshot deepest-first, then the parent - the same enumerated-PID + # shape Stop-HostNativeService already uses. + Stop-DescendantSnapshot ` + -DescendantIds $descendantIds ` + -Identities $descendantIdentities ` + -ProbeFn $IdentityProbeFn ` + -StopFn $StopProcessFn + $Process.Kill() } - $Process.Kill() } - } - catch { - # An already-exited PARENT is the only tolerated failure, and it is not a - # licence to stop: its descendants outlive it, so terminate the snapshot - # explicitly instead of swallowing the exception as before. - if (-not $Process.HasExited) { - throw "Process tree termination failed for PID $($Process.Id): $($_.Exception.Message)" + catch { + # An already-exited PARENT is the only tolerated failure, and it is not + # a licence to stop: its descendants outlive it, so the containment + # loop below still terminates and proves them instead of swallowing + # the exception as before. + if (-not $Process.HasExited) { + throw "Process tree termination failed for PID $($Process.Id): $($_.Exception.Message)" + } } - for ($i = $descendantIds.Count - 1; $i -ge 0; $i--) { - & $StopProcessFn ([int]$descendantIds[$i]) + if (-not $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { + throw "Process tree for PID $($Process.Id) did not terminate within $TimeoutMs ms." } } - if (-not $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { - throw "Process tree for PID $($Process.Id) did not terminate within $TimeoutMs ms." - } + # Bounded containment fixed point. Runs on EVERY path, including the exited + # parent that used to return here, and including a successful tree kill - a + # child created after the snapshot is invisible to both. The parent stays a + # root even once it is dead: this call holds its Process handle, so its PID + # cannot be recycled underneath us, and on Windows a just-orphaned child is + # still reachable through the dead parent's ppid link. Every other root has + # to still be the incarnation we recorded. $survivors = @($descendantIds) - while ($survivors.Count -gt 0) { - $survivors = @($survivors | Where-Object { - $descendantProcessId = [int]$_ - Test-PlatformProcessIdentityMatch ` - -Reference $descendantIdentities[$descendantProcessId] ` - -Current (& $IdentityProbeFn $descendantProcessId) - }) - if ($survivors.Count -eq 0) { break } + while ($true) { + $newDescendantIds = @(Update-DescendantSnapshot ` + -RootProcessIds (@($parentProcessId) + $survivors) ` + -KnownProcessIds $descendantIds ` + -Identities $descendantIdentities ` + -LookupFn $ChildPidLookup ` + -ProbeFn $IdentityProbeFn) + if ($newDescendantIds.Count -gt 0) { $descendantIds += $newDescendantIds } + Stop-DescendantSnapshot ` + -DescendantIds $descendantIds ` + -Identities $descendantIdentities ` + -ProbeFn $IdentityProbeFn ` + -StopFn $StopProcessFn + $survivors = @($descendantIds | Where-Object { + $descendantProcessId = [int]$_ + Test-PlatformProcessIdentityMatch ` + -Reference $descendantIdentities[$descendantProcessId] ` + -Current (& $IdentityProbeFn $descendantProcessId) + }) + if ($survivors.Count -eq 0 -and $newDescendantIds.Count -eq 0) { break } if ($budget.ElapsedMilliseconds -ge $TimeoutMs) { break } & $SleepFn 50 } diff --git a/scripts/self-referential-bootstrap-ledger.json b/scripts/self-referential-bootstrap-ledger.json index d45d19dd6..bf87c2412 100644 --- a/scripts/self-referential-bootstrap-ledger.json +++ b/scripts/self-referential-bootstrap-ledger.json @@ -154,9 +154,11 @@ "opened_at": "2026-08-12T02:38:49Z", "reason": "the canonical Linux transport rebuilds only from freshly fetched origin/main and refuses an unmerged revision, so this branch cannot exercise its changed deploy resolver, its Deployment-profile locality assertion, or its process-tree terminator as canonical post-change evidence before merge; only a mainline fixpoint rebuild can produce that proof", "verification_mechanism_paths": [ + ".github/workflows/ci.yml", "scripts/deploy.ps1", "scripts/lib/host-native-launcher.ps1", "scripts/self-referential-bootstrap-ledger.json", + "scripts/verification-manifest.json", "scripts/verify-all.ps1" ], "verification_contract": { diff --git a/scripts/tests/test-deploy-dryrun.ps1 b/scripts/tests/test-deploy-dryrun.ps1 index 0c12e052a..ad362ee7e 100644 --- a/scripts/tests/test-deploy-dryrun.ps1 +++ b/scripts/tests/test-deploy-dryrun.ps1 @@ -211,6 +211,36 @@ Assert-True (-not $invalidAuthorityOutput.Contains($invalidAuthorityToken)) 'inv Remove-Item -LiteralPath $invalidAuthorityEnv, $invalidAuthorityOut, $invalidAuthorityErr -ErrorAction SilentlyContinue Write-TestPass 'non-loopback runtime authority base rejected without secret disclosure' +# Test 12b: the dry-run preflight still adjudicates KIT_CONTROL_URL. +# The AUTHORITATIVE resolution moved after the Phase 2 missing-key merge (so a +# repaired env file takes effect in the same run), which put it past the -DryRun +# exit. The merge only APPENDS an absent key with a default and never rewrites an +# existing value, so an unusable authority is unfixable and has to surface as a +# Phase 1 hard fail - otherwise the required preflight reports success and the +# real deploy discovers it only after Phase 2 has modified the environment. +# One representative rejection is enough here: this proves the dry-run PATH still +# adjudicates, while the resolver's own shape matrix is unit-tested in +# test-host-native-launcher.ps1. +$remoteKitControlEnv = Join-Path $repoRoot 'scripts\.run\deploy-kit-control-remote-test.env' +$remoteKitControlOut = Join-Path $repoRoot 'scripts\.run\deploy-kit-control-remote-test.out.log' +$remoteKitControlErr = Join-Path $repoRoot 'scripts\.run\deploy-kit-control-remote-test.err.log' +Set-Content -LiteralPath $remoteKitControlEnv -Encoding ascii -Value @( + 'KIT_CONTROL_URL=http://192.0.2.51:49101', + 'RUNTIME_STORAGE_ROOT=C:\tmp\ai-bim-governance-kit-control-remote-test\storage' +) +$remoteKitControlProc = Start-Process -FilePath 'powershell.exe' ` + -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$deploy,'-DryRun','-EnvFile',$remoteKitControlEnv) ` + -RedirectStandardOutput $remoteKitControlOut ` + -RedirectStandardError $remoteKitControlErr ` + -Wait -PassThru -WindowStyle Hidden +$remoteKitControlOutput = ((Get-Content -LiteralPath $remoteKitControlOut -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content -LiteralPath $remoteKitControlErr -Raw -ErrorAction SilentlyContinue)) +Assert-Equal 0 $remoteKitControlProc.ExitCode 'dry-run stays a read-only audit while reporting an unusable Kit control authority' +Assert-True ($remoteKitControlOutput -match 'kit_control_url_unusable') 'dry-run reports a non-local KIT_CONTROL_URL as a preflight hard fail' +Assert-True ($remoteKitControlOutput -match 'loopback or an address assigned to this host') 'dry-run tells the operator why the Kit control authority was rejected' +Assert-True (-not $remoteKitControlOutput.Contains('192.0.2.51')) 'dry-run rejects the Kit control authority without echoing its value' +Remove-Item -LiteralPath $remoteKitControlEnv, $remoteKitControlOut, $remoteKitControlErr -ErrorAction SilentlyContinue +Write-TestPass 'dry-run preflight still rejects a non-local Kit control authority' + # A configured A4 token must meet the coordinator boundary without being echoed. $shortA4Token = 's7x' $shortA4Env = Join-Path $repoRoot 'scripts\.run\deploy-a4-token-short-test.env' @@ -424,6 +454,9 @@ Write-Host "`n=== test-deploy-dryrun.ps1: ALL PASSED ===" -ForegroundColor Green 'deploy-runtime-authority-invalid-test.env', 'deploy-runtime-authority-invalid-test.out.log', 'deploy-runtime-authority-invalid-test.err.log', + 'deploy-kit-control-remote-test.env', + 'deploy-kit-control-remote-test.out.log', + 'deploy-kit-control-remote-test.err.log', 'deploy-a4-token-short-test.env', 'deploy-a4-token-short-test.out.log', 'deploy-a4-token-short-test.err.log', diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index ec538f236..8bbb61fce 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -793,6 +793,275 @@ time.sleep(120) Assert-True ($survivorFailure -match "left descendant PID\(s\) $unkillableDescendantId running") 'process-tree terminator fails closed when a snapshotted descendant survives' Assert-True ($survivorStopwatch.Elapsed.TotalSeconds -lt 15) 'process-tree terminator bounds the descendant wait before failing closed' Write-TestPass 'process-tree terminator fails closed on a surviving descendant' + + # Case 3 (#489 L1-COR-001): the parent exits BEFORE the helper is entered. + # Neither Windows nor Linux cascades termination, so the descendants are still + # running - the old `if ($Process.HasExited) { return }` reported containment + # without ever looking at them. An exited parent excuses the parent kill/wait + # only; the sweep, the termination and the proof still have to happen. + $orphanFixture = Join-Path $treeSandbox 'orphan-fixture.py' + $orphanPidFile = Join-Path $treeSandbox 'orphan-pids.json' + [System.IO.File]::WriteAllText($orphanFixture, $treeSource) + $orphanProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $orphanFixture -PidPath $orphanPidFile + $orphanPids = @(Get-Content -Raw -LiteralPath $orphanPidFile | ConvertFrom-Json) + $orphanParentId = [int]$orphanPids[0] + $orphanChildId = [int]$orphanPids[1] + $orphanProcess.Kill() + [void]$orphanProcess.WaitForExit(5000) + Assert-True $orphanProcess.HasExited 'orphan fixture parent has already exited before the helper is entered' + Assert-True ($null -ne (Get-PlatformProcessIdentity -ProcessId $orphanChildId)) 'orphaned descendant outlives the parent that spawned it' + $orphanStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + # Sampled INSIDE the try, before the sandbox cleanup below: killing the child + # here and asserting afterwards would let the test's own cleanup satisfy the + # containment claim the helper is supposed to prove. + $orphanChildIdentityAfterStop = 'never-sampled' + try { + Stop-HostNativeProcessTreeAndWait -Process $orphanProcess -TimeoutMs 5000 ` + -ChildPidLookup { + param($parentId) + # A re-parented descendant is no longer reachable through the dead + # parent's ppid link, so the caller's own record stands in for it. + if ([int]$parentId -eq $orphanParentId) { return @($orphanChildId) } + return @() + }.GetNewClosure() + $orphanChildIdentityAfterStop = Get-PlatformProcessIdentity -ProcessId $orphanChildId + } + finally { + $orphanStopwatch.Stop() + Stop-Process -Id $orphanChildId -Force -ErrorAction SilentlyContinue + } + Assert-True ($null -eq $orphanChildIdentityAfterStop) 'process-tree terminator contains the descendants of an already-exited parent' + Assert-True ($orphanStopwatch.Elapsed.TotalSeconds -lt 15) 'already-exited-parent containment stays inside the bounded window' + Write-TestPass 'process-tree terminator sweeps descendants when the parent exited before entry' + + # Case 4 (#489 L1-COR-001): the same entry state, but the descendant cannot be + # killed. Silent success is exactly the defect; it must fail closed instead. + $orphanFailFixture = Join-Path $treeSandbox 'orphan-fail-fixture.py' + $orphanFailPidFile = Join-Path $treeSandbox 'orphan-fail-pids.json' + [System.IO.File]::WriteAllText($orphanFailFixture, $survivorSource) + $orphanFailProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $orphanFailFixture -PidPath $orphanFailPidFile + $orphanFailProcess.Kill() + [void]$orphanFailProcess.WaitForExit(5000) + $orphanFailFailure = '' + try { + Stop-HostNativeProcessTreeAndWait -Process $orphanFailProcess -TimeoutMs 1000 ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -eq $unkillableDescendantId) { return @() } + return @($unkillableDescendantId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -eq $unkillableDescendantId) { return $survivorIdentity } + return $null + }.GetNewClosure() ` + -StopProcessFn { param($procId) } | Out-Null + } + catch { + $orphanFailFailure = $_.Exception.Message + } + Assert-True ($orphanFailFailure -match "left descendant PID\(s\) $unkillableDescendantId running") 'process-tree terminator fails closed when an already-exited parent leaves a live descendant' + Write-TestPass 'already-exited parent with a surviving descendant fails closed' + + # Case 5 (#489 L1-COR-002): a snapshotted descendant PID can be recycled before + # the stop resolves it. Terminating it by bare PID would kill an unrelated host + # process, and the later identity check would still read "our descendant is + # gone". The stop primitive must therefore be identity-gated too. + $recycledFixture = Join-Path $treeSandbox 'recycled-fixture.py' + $recycledPidFile = Join-Path $treeSandbox 'recycled-pids.json' + [System.IO.File]::WriteAllText($recycledFixture, $survivorSource) + $recycledProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $recycledFixture -PidPath $recycledPidFile + $recycledDescendantId = 424243 + $recycledSnapshotIdentity = [pscustomobject]@{ + ProcessId = $recycledDescendantId + BirthToken = 'snapshot-birth-token' + ExecutablePath = '' + CommandLine = '' + } + $recycledCurrentIdentity = [pscustomobject]@{ + ProcessId = $recycledDescendantId + BirthToken = 'recycled-birth-token' + ExecutablePath = '' + CommandLine = '' + } + $recycledProbeCalls = [System.Collections.Generic.List[int]]::new() + $recycledStopCalls = [System.Collections.Generic.List[int]]::new() + try { + Stop-HostNativeProcessTreeAndWait -Process $recycledProcess -TimeoutMs 2000 ` + -TreeKillCapabilityProbeFn { $false } ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -eq $recycledDescendantId) { return @() } + return @($recycledDescendantId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -ne $recycledDescendantId) { return $null } + $recycledProbeCalls.Add([int]$procId) + if ($recycledProbeCalls.Count -eq 1) { return $recycledSnapshotIdentity } + return $recycledCurrentIdentity + }.GetNewClosure() ` + -StopProcessFn { + param($procId) + $recycledStopCalls.Add([int]$procId) + }.GetNewClosure() + } + finally { + if (-not $recycledProcess.HasExited) { + $recycledProcess.Kill() + [void]$recycledProcess.WaitForExit(5000) + } + } + Assert-True ($recycledStopCalls.Count -eq 0) 'process-tree terminator never terminates a descendant PID whose incarnation changed' + Assert-True ($recycledProbeCalls.Count -ge 2) 'process-tree terminator re-probes descendant identity before terminating it' + Write-TestPass 'process-tree terminator revalidates descendant identity before the stop' + + # Case 6 (#489 L1-TG-003): drive the .NET Framework / Windows PowerShell 5.1 + # branch as BEHAVIOUR from this PowerShell 7 run by injecting the capability + # decision. Source-order assertions cannot show that the fallback really + # terminates the tree deepest-first and proves it gone. + $chainFixture = Join-Path $treeSandbox 'chain-fixture.py' + $chainPidFile = Join-Path $treeSandbox 'chain-pids.json' + $chainSource = @' +import json +import os +import pathlib +import subprocess +import sys +import time + +pid_path = pathlib.Path(sys.argv[1]) +depth = int(sys.argv[2]) if len(sys.argv) > 2 else 2 + +descendants = [] +if depth > 0: + child_path = pid_path.with_name(pid_path.name + "." + str(depth)) + subprocess.Popen([sys.executable, sys.argv[0], str(child_path), str(depth - 1)]) + for _ in range(200): + try: + descendants = json.loads(child_path.read_text(encoding="utf-8")) + break + except (OSError, ValueError): + time.sleep(0.05) + +pid_path.write_text(json.dumps([os.getpid()] + descendants), encoding="utf-8") +time.sleep(120) +'@ + [System.IO.File]::WriteAllText($chainFixture, $chainSource) + $chainProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $chainFixture -PidPath $chainPidFile + $chainPids = @(Get-Content -Raw -LiteralPath $chainPidFile | ConvertFrom-Json) + Assert-Equal 3 $chainPids.Count 'fallback fixture records a three-level parent/child/grandchild chain' + $chainChildId = [int]$chainPids[1] + $chainGrandchildId = [int]$chainPids[2] + $fallbackStopCalls = [System.Collections.Generic.List[int]]::new() + $fallbackStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Stop-HostNativeProcessTreeAndWait -Process $chainProcess -TimeoutMs 5000 ` + -TreeKillCapabilityProbeFn { $false } ` + -StopProcessFn { + param($procId) + $fallbackStopCalls.Add([int]$procId) + Stop-Process -Id ([int]$procId) -Force -ErrorAction SilentlyContinue + }.GetNewClosure() + } + finally { + $fallbackStopwatch.Stop() + foreach ($chainPid in $chainPids) { + Stop-Process -Id ([int]$chainPid) -Force -ErrorAction SilentlyContinue + } + } + Assert-True ($fallbackStopCalls.Contains($chainGrandchildId)) 'forced fallback terminates the deepest descendant through the PID stop primitive' + Assert-True ($fallbackStopCalls.Contains($chainChildId)) 'forced fallback terminates the intermediate descendant through the PID stop primitive' + Assert-True ($fallbackStopCalls.IndexOf($chainGrandchildId) -lt $fallbackStopCalls.IndexOf($chainChildId)) 'forced fallback terminates the descendant snapshot deepest-first' + foreach ($chainPid in $chainPids) { + Assert-True ($null -eq (Get-PlatformProcessIdentity -ProcessId ([int]$chainPid))) "forced fallback proves PID $chainPid exited" + } + Assert-True ($fallbackStopwatch.Elapsed.TotalSeconds -lt 15) 'forced fallback returns inside the bounded cleanup window' + Write-TestPass 'forced no-Kill(bool) fallback terminates the tree deepest-first and proves it gone' + + # Case 7 (#489 L1-SEC-002): one fixed snapshot is not containment. A snapshotted + # process can spawn another child before it dies, and that child is absent from + # both the stop list and the success check unless containment re-enumerates. + $lateSpawnFixture = Join-Path $treeSandbox 'late-spawn-fixture.py' + $lateSpawnPidFile = Join-Path $treeSandbox 'late-spawn-pids.json' + [System.IO.File]::WriteAllText($lateSpawnFixture, $survivorSource) + $lateSpawnProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $lateSpawnFixture -PidPath $lateSpawnPidFile + $lateSpawnParentId = [int]$lateSpawnProcess.Id + $lateSpawnDescendantId = 424244 + $lateSpawnIdentity = [pscustomobject]@{ + ProcessId = $lateSpawnDescendantId + BirthToken = 'late-spawn-birth-token' + ExecutablePath = '' + CommandLine = '' + } + $lateSpawnLookups = [System.Collections.Generic.List[int]]::new() + $lateSpawnStopped = [System.Collections.Generic.List[int]]::new() + try { + Stop-HostNativeProcessTreeAndWait -Process $lateSpawnProcess -TimeoutMs 3000 ` + -TreeKillCapabilityProbeFn { $false } ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -ne $lateSpawnParentId) { return @() } + $lateSpawnLookups.Add([int]$parentId) + # Empty on the snapshot pass; the child shows up only afterwards, + # exactly like a real post-snapshot spawn. + if ($lateSpawnLookups.Count -eq 1) { return @() } + return @($lateSpawnDescendantId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -ne $lateSpawnDescendantId) { return $null } + if ($lateSpawnStopped.Contains($lateSpawnDescendantId)) { return $null } + return $lateSpawnIdentity + }.GetNewClosure() ` + -StopProcessFn { + param($procId) + $lateSpawnStopped.Add([int]$procId) + }.GetNewClosure() + } + finally { + if (-not $lateSpawnProcess.HasExited) { + $lateSpawnProcess.Kill() + [void]$lateSpawnProcess.WaitForExit(5000) + } + } + Assert-True ($lateSpawnStopped.Contains($lateSpawnDescendantId)) 'containment re-enumerates and terminates a descendant spawned after the snapshot' + Assert-True ($lateSpawnLookups.Count -ge 2) 'containment enumerates descendants more than once before declaring success' + Write-TestPass 'process-tree terminator contains post-snapshot descendants' + + # Case 8 (#489 L1-TG-003): the forced fallback must fail closed on a survivor + # too - the branch carries the retained races, so its failure mode is pinned. + $fallbackFailFixture = Join-Path $treeSandbox 'fallback-fail-fixture.py' + $fallbackFailPidFile = Join-Path $treeSandbox 'fallback-fail-pids.json' + [System.IO.File]::WriteAllText($fallbackFailFixture, $survivorSource) + $fallbackFailProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $fallbackFailFixture -PidPath $fallbackFailPidFile + $fallbackFailFailure = '' + try { + Stop-HostNativeProcessTreeAndWait -Process $fallbackFailProcess -TimeoutMs 1000 ` + -TreeKillCapabilityProbeFn { $false } ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -eq $unkillableDescendantId) { return @() } + return @($unkillableDescendantId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -eq $unkillableDescendantId) { return $survivorIdentity } + return $null + }.GetNewClosure() ` + -StopProcessFn { param($procId) } | Out-Null + } + catch { + $fallbackFailFailure = $_.Exception.Message + } + finally { + if (-not $fallbackFailProcess.HasExited) { + $fallbackFailProcess.Kill() + [void]$fallbackFailProcess.WaitForExit(5000) + } + } + Assert-True ($fallbackFailFailure -match "left descendant PID\(s\) $unkillableDescendantId running") 'forced fallback fails closed when a snapshotted descendant survives' + Write-TestPass 'forced no-Kill(bool) fallback fails closed on a surviving descendant' } finally { Remove-TestSandbox -Path $treeSandbox diff --git a/scripts/verification-manifest.json b/scripts/verification-manifest.json index db668c4c4..882f7558a 100644 --- a/scripts/verification-manifest.json +++ b/scripts/verification-manifest.json @@ -162,7 +162,8 @@ "path_globs": [ "scripts/deploy.ps1", "scripts/dev/rebuild-test-deploy.ps1", "scripts/lib/rebuild-test-deploy.ps1", "scripts/lib/design-assets.ps1", "scripts/lib/host-native-launcher.ps1", "scripts/lib/StructLog.psm1", - "scripts/tests/test-rebuild-test-deploy.ps1", "scripts/tests/test-verify-all.ps1", "scripts/tests/test-helpers.ps1" + "scripts/tests/test-rebuild-test-deploy.ps1", "scripts/tests/test-verify-all.ps1", "scripts/tests/test-helpers.ps1", + "scripts/tests/test-host-native-launcher.ps1" ] }, { @@ -398,7 +399,7 @@ "ci_job": "powershell static analysis", "result_artifact": null }, { - "id": "rebuild-test-deploy", "display_name": "rebuild/test-deploy contracts", "path_globs": ["scripts/deploy.ps1", "scripts/dev/rebuild-test-deploy.ps1", "scripts/lib/rebuild-test-deploy.ps1", "scripts/lib/design-assets.ps1", "scripts/lib/host-native-launcher.ps1", "scripts/lib/StructLog.psm1", "scripts/tests/test-rebuild-test-deploy.ps1", "scripts/tests/test-verify-all.ps1", "scripts/tests/test-helpers.ps1"], + "id": "rebuild-test-deploy", "display_name": "rebuild/test-deploy contracts", "path_globs": ["scripts/deploy.ps1", "scripts/dev/rebuild-test-deploy.ps1", "scripts/lib/rebuild-test-deploy.ps1", "scripts/lib/design-assets.ps1", "scripts/lib/host-native-launcher.ps1", "scripts/lib/StructLog.psm1", "scripts/tests/test-rebuild-test-deploy.ps1", "scripts/tests/test-verify-all.ps1", "scripts/tests/test-helpers.ps1", "scripts/tests/test-host-native-launcher.ps1"], "owner": "deployment", "fast_gates": [], "contract_gates": ["rebuild-test-deploy"], "slow_evidence_gates": [], "required_when": { "predicate": "changed_path_class", "any_of": ["rebuild-test-deploy"] }, "skip_reason": "path_not_affected", "default_profiles": [], "ci_output": "rebuild_test_deploy", From 26f056dbace0f18ccbc90cf52ba981f5152023a0 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:08:28 +0800 Subject: [PATCH 05/12] docs(evidence): bind mechanism-hardening-2 bootstrap evidence to its reviewed head The PR #513 review threads found this bundle's evidence unbindable: it recorded only the baseline commit, so no reader could tie the PASS results to the code under review, and the PASS lines carried bare command ids with no invocation or command-map reference. The record now splits into the two rounds that produced it, pins the reviewed head commit `110c657` with the clean-worktree result observed at it, names the immutable command map that resolves the ids, and writes the resolved invocation inline for the three ids that map does not carry. That third point also corrects a partial disclosure: the earlier note covered `test-deploy-dryrun` and `test-deploy-env-fallback` but omitted `test-stop-all-single-pid`, which is in the same position. All three have executable sources under scripts/tests/ and pass; none is a verification-contract command id. Promoting them into the contract would enlarge this entry's fixpoint obligation, so that decision is left to the ledger owner rather than taken inside a review-fix round. Refs #489. Co-Authored-By: Claude Fable 5 --- .../self-referential-bootstrap/README.md | 7 +++++- .../verification.txt | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md index 0e4457ad5..31c6366ce 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md @@ -5,6 +5,7 @@ - `stack_kind=self_referential_bootstrap` - Pull request: see the ledger entry `mechanism-hardening-2` (`pr` field is the binding record) - Baseline: freshly fetched `origin/main` at `472192386f8402cf19a29005daf25556d26f222c` +- Reviewed head: `110c657fd620e3bdbac4379ac716da99d37848b9` (round 2), worktree clean — `git status --porcelain` produced no output at that commit - This is isolated branch bootstrap evidence. It is not canonical post-change evidence and does not claim full-system E2E completion. ## Scope @@ -25,7 +26,11 @@ The canonical deployment transport rebuilds the Linux test target only from fres ## What this branch did verify -Local mechanism suites on the branch head, on Windows with PowerShell 7.5.4. The recorded results are in `verification.txt`. +Local mechanism suites on the branch head, on Windows with PowerShell 7.5.4. The recorded results are in `verification.txt`, split into the two rounds that produced them: the initial bundle at the baseline, and the PR #513 review round at the reviewed head above. Each `PASS` line names either a command id that resolves through the immutable command map in `scripts/tests/test-self-referential-bootstrap.ps1`, or its resolved invocation inline when that map does not carry the id. + +## Round 2: PR #513 ship-gate findings + +The Codex tri-adversarial ship-gate returned NO-SHIP on four findings against `Stop-HostNativeProcessTreeAndWait`, and the PR review threads named the same defects. All four are closed at the reviewed head: the pre-entry `HasExited` return no longer skips descendant containment, descendant stops are identity-revalidated against PID reuse, containment is a bounded re-enumerating fixed point rather than one snapshot, and the tree-kill capability decision is injectable so the Windows PowerShell 5.1 fallback is exercised as behaviour. The round also restored `-DryRun` adjudication of `KIT_CONTROL_URL` and put `test-host-native-launcher.ps1` into the required `rebuild-test-deploy` CI job, which is why `.github/workflows/ci.yml` and `scripts/verification-manifest.json` joined this entry's `verification_mechanism_paths`. ## Limits diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt index 0dc1ed1b2..48dbbac6a 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt @@ -4,27 +4,43 @@ ledger_entry=mechanism-hardening-2 base_commit=472192386f8402cf19a29005daf25556d26f222c host=windows pwsh=7.5.4 gitnexus=NOT_APPLICABLE (the GitNexus index carries no PowerShell symbols; `impact Stop-HostNativeProcessTreeAndWait` and `impact Resolve-HostNativeKitControlUrl` both returned target-not-found, so callers were established by repository-wide grep instead) +command_resolution=every `PASS ` below without an inline invocation resolves through the immutable command map `$commandSpecById` in scripts/tests/test-self-referential-bootstrap.ps1 at the recorded head; the ids that map does not carry are written with their resolved invocation inline +--- round 1: initial bundle, recorded at base_commit --- PASS test-deploy-governance-static PASS test-verify-all PASS test-host-native-launcher PASS test-host-native-child-launch -PASS test-deploy-dryrun -PASS test-deploy-env-fallback +PASS test-deploy-dryrun (pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-dryrun.ps1) +PASS test-deploy-env-fallback (pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-env-fallback.ps1) PASS test-rebuild-test-deploy PASS test-platform-adapter PASS test-preflight-ports -PASS test-stop-all-single-pid +PASS test-stop-all-single-pid (pwsh -NoProfile -NonInteractive -File scripts/tests/test-stop-all-single-pid.ps1) PASS test-kit-log-probe PASS test-deploy-target-registry PASS test-remote-deploy-transport PASS test-self-referential-bootstrap PASS test-agent-governance-check PASS invoke-powershell-static -NOTE test-deploy-dryrun and test-deploy-env-fallback pass on this branch but are absent from the immutable command map in scripts/tests/test-self-referential-bootstrap.ps1, which is itself a gated file; they are therefore recorded here as evidence but are not verification-contract command ids +NOTE test-deploy-dryrun, test-deploy-env-fallback and test-stop-all-single-pid each have an executable source under scripts/tests/ and pass on this branch, but none of the three is in the immutable command map, so none of them is a verification-contract command id; all three are supporting evidence only. Adding them to the map and to the entry's ordered command_ids would enlarge this entry's fixpoint obligation, so that scope decision is left to the ledger owner rather than taken inside a review-fix round. PASS deploy-dry-run-operator-path (scripts/deploy.ps1 -DryRun exits 0 and leaves the working tree clean) PASS verify-all-deployment-plan-only-windows (scripts/verify-all.ps1 -Profile Deployment -PlanOnly exits 0) NOT_RUN verify-all-deployment-plan-only-linux (no canonical Linux session was opened for this bundle) NOT_RUN canonical-linux-rebuild (owed at fixpoint; no baseline rebuild was executed for this bundle) NOT_RUN canonical-linux-deployment-verify (owed at fixpoint) +--- round 2: PR #513 ship-gate NO-SHIP findings and review threads --- +reviewed_head_commit=110c657fd620e3bdbac4379ac716da99d37848b9 +reviewed_head_worktree=clean (`git status --porcelain` produced no output at the recorded commit) +reviewed_host=windows pwsh=7.5.4 +reviewed_scope=scripts/lib/host-native-launcher.ps1, scripts/deploy.ps1, scripts/tests/test-host-native-launcher.ps1, scripts/tests/test-deploy-dryrun.ps1, .github/workflows/ci.yml, scripts/verification-manifest.json, scripts/self-referential-bootstrap-ledger.json +PASS test-host-native-launcher +PASS test-deploy-governance-static +PASS test-verify-all +PASS test-self-referential-bootstrap +PASS invoke-powershell-static +PASS test-rebuild-test-deploy +PASS test-agent-governance-check +PASS test-deploy-dryrun (pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-dryrun.ps1) +NOT_RUN the remaining round-1 command ids were not re-executed in round 2; nothing in the round-2 scope reaches them LIMIT canonical-post-change-verification=pending merge and mainline fixpoint rebuild LIMIT full-system-e2e=not claimed From d63453024f59b13714ed222ea14bdc34a2890253 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:50:14 +0800 Subject: [PATCH 06/12] fix(launcher): gate orphan sweeps by platform, require a clean pass, spend one budget Second Codex tri-adversarial round on PR #513 returned NO-SHIP on three more findings against Stop-HostNativeProcessTreeAndWait. All three are real; none was refuted. L1-COR-001 (high): the exited-parent sweep added in 110c657 is only sound where the OS keeps the creator PID on an orphan. Windows does - measured here, a killed parent's PID still resolves its orphaned child and its conhost through Win32_Process.ParentProcessId, and the PID cannot be recycled while this call holds the exited process handle. Linux does not: the kernel re-parents orphans to init or the nearest subreaper, so both discovery passes return empty, and an empty pass was being read as containment. That platform fact now lives in Test-OrphanRediscoverySupported alongside the other platform primitives, and it is the same conclusion the converter containment work reached in #509, which had to own a job object / process group established at launch rather than trust a PPID link. Where rediscovery is unsupported and the parent had already exited on entry, the helper now fails closed and says so: containment is not provable via PPID on this platform, and the authoritative boundary is the caller. A new -KnownDescendantProcessIds parameter is the escape hatch - a descendant record captured before the parent could exit replaces the link discovery lost, and the helper then contains and proves that set normally. L1-SEC-001 (high): the loop broke cleanly only when survivors AND newly discovered descendants were both zero, but the deadline branch broke on time alone and the post-loop check only tested survivors. A deadline pass that discovered a descendant and successfully stopped it therefore left zero survivors and returned success, with no fixed point ever reached. Success now requires a recorded clean pass; reaching the deadline without one throws. L1-COR-002 (medium): TimeoutMs was not one budget. The stopwatch started after discovery and the parent wait still received the full allowance. It now starts before discovery, the parent wait receives only the remainder, and no further containment pass begins on an exhausted budget. Verified against the previous implementation to confirm these are real, not theoretical: the churn scenario returned SUCCESS after discovering twelve descendants, and the slow-discovery scenario spent 2786ms against an advertised 1500ms bound. Both now fail closed and stay inside the budget. Four new behavioural cases: production defaults against an already-exited parent asserting whichever branch this platform is on (no injected lookup), the POSIX fail-closed path driven from a Windows run via the injected capability gate, the pre-exit record proving containment on that same simulated platform, and one case each for the clean-pass requirement and the end-to-end budget. The gated deployment static test's three pinned literals are unchanged; the parent wait keeps `$Process.WaitForExit($TimeoutMs)` honest by living in a nested helper whose own TimeoutMs parameter IS the remaining allowance. Refs #489. Co-Authored-By: Claude Fable 5 --- scripts/lib/host-native-launcher.ps1 | 89 ++++++++- scripts/lib/platform/platform-adapter.ps1 | 20 ++ scripts/tests/test-host-native-launcher.ps1 | 198 ++++++++++++++++++-- 3 files changed, 285 insertions(+), 22 deletions(-) diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 9901088df..9023c0a59 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -548,6 +548,19 @@ function Stop-HostNativeProcessTreeAndWait { # 5. One fixed snapshot is not containment (#513 gate L1-SEC-002): a # snapshotted process can spawn another child before it dies, and that # child appears in neither the stop list nor the success check. + # 6. Sweeping an already-exited parent is only possible where the OS keeps + # the creator PID on an orphan (#513 gate r2 L1-COR-001). Linux + # re-parents orphans to init/subreaper, so BOTH discovery passes come + # back empty and an empty pass would read as containment. There, a + # pre-exit descendant record is the only proof, and without one this + # fails closed instead of succeeding. + # 7. Reaching the deadline is not a clean pass (#513 gate r2 L1-SEC-001). + # Success requires a pass that observed NEITHER a survivor NOR a newly + # discovered descendant; an empty survivor set at the deadline is not + # the same thing and must fail closed. + # 8. TimeoutMs is ONE end-to-end budget (#513 gate r2 L1-COR-002). The + # stopwatch starts before discovery and every bounded operation after it + # - including the parent wait - receives only the remaining allowance. # # Fix: snapshot the descendant PID set BEFORE terminating (afterwards the # parent/child links are gone and orphans are re-parented), guard the tree @@ -564,6 +577,12 @@ function Stop-HostNativeProcessTreeAndWait { param( [Parameter(Mandatory = $true)][System.Diagnostics.Process] $Process, [ValidateRange(1, 60000)][int] $TimeoutMs = 5000, + # A descendant record captured BEFORE the parent could exit. On a platform + # that re-parents orphans this is the only thing that can prove containment + # for a parent that was already gone on entry, because the PPID link that + # discovery walks no longer names the creator. Callers that hold an OS + # boundary (process group / job) do not need it. + [AllowEmptyCollection()][int[]] $KnownDescendantProcessIds = @(), # Injectable so the containment postcondition is testable without an # actually unkillable process. [scriptblock] $ChildPidLookup = { @@ -589,6 +608,12 @@ function Stop-HostNativeProcessTreeAndWait { # carries the two races above. [scriptblock] $TreeKillCapabilityProbeFn = { $null -ne [System.Diagnostics.Process].GetMethod('Kill', [type[]]@([bool])) + }, + # Injectable so the POSIX fail-closed path is provable from a Windows run + # and vice versa. See Test-OrphanRediscoverySupported for the platform + # facts this gate encodes. + [scriptblock] $OrphanRediscoveryProbeFn = { + Test-OrphanRediscoverySupported } ) @@ -654,18 +679,56 @@ function Stop-HostNativeProcessTreeAndWait { } } + function Wait-ParentExitWithinBudget { + # $TimeoutMs here is the REMAINING allowance out of the caller's single + # end-to-end budget, never the caller's total: discovery and termination + # already spent part of it before this runs. + param( + [Parameter(Mandatory = $true)][System.Diagnostics.Process] $Process, + [Parameter(Mandatory = $true)][int] $TimeoutMs + ) + if (-not $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { return $false } + return $true + } + + # ONE end-to-end budget: started before discovery, so every later bounded + # operation spends what is left of it rather than a fresh full allowance. + $budget = [System.Diagnostics.Stopwatch]::StartNew() + $parentProcessId = [int]$Process.Id $parentAlreadyExited = $Process.HasExited + $seededDescendantIds = @(@($KnownDescendantProcessIds) | + ForEach-Object { [int]$_ } | + Where-Object { $_ -ne $parentProcessId } | + Select-Object -Unique) + + # An already-exited parent is only sweepable where the OS keeps the creator + # PID on an orphan. Where it does not, discovery has nothing left to walk, so + # an empty result is ignorance rather than containment - fail closed and say + # where the authoritative boundary actually is. + if ($parentAlreadyExited -and + $seededDescendantIds.Count -eq 0 -and + -not [bool](& $OrphanRediscoveryProbeFn)) { + throw ("Process tree for PID $parentProcessId cannot be proven contained: the parent had " + + 'already exited on entry and this platform re-parents orphans, so containment is not ' + + 'provable via PPID here. The authoritative containment boundary is the caller - hold an ' + + 'OS process group or job established at launch, or capture the descendant set before the ' + + 'parent can exit and pass it as -KnownDescendantProcessIds.') + } $descendantIdentities = @{} - $descendantIds = @(Update-DescendantSnapshot ` - -RootProcessIds @($parentProcessId) ` - -KnownProcessIds @() ` + foreach ($seededId in $seededDescendantIds) { + $descendantIdentities[$seededId] = (& $IdentityProbeFn $seededId) + } + # Seeded ids first, discovered ones after: reverse iteration then stops the + # deepest-discovered members before the caller's recorded direct children. + $descendantIds = @($seededDescendantIds) + @(Update-DescendantSnapshot ` + -RootProcessIds (@($parentProcessId) + $seededDescendantIds) ` + -KnownProcessIds $seededDescendantIds ` -Identities $descendantIdentities ` -LookupFn $ChildPidLookup ` -ProbeFn $IdentityProbeFn) - $budget = [System.Diagnostics.Stopwatch]::StartNew() $supportsTreeKill = [bool](& $TreeKillCapabilityProbeFn) if (-not $parentAlreadyExited) { try { @@ -693,7 +756,8 @@ function Stop-HostNativeProcessTreeAndWait { throw "Process tree termination failed for PID $($Process.Id): $($_.Exception.Message)" } } - if (-not $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { + $parentWaitMs = [int]($TimeoutMs - $budget.ElapsedMilliseconds) + if ($parentWaitMs -le 0 -or -not (Wait-ParentExitWithinBudget -Process $Process -TimeoutMs $parentWaitMs)) { throw "Process tree for PID $($Process.Id) did not terminate within $TimeoutMs ms." } } @@ -706,7 +770,11 @@ function Stop-HostNativeProcessTreeAndWait { # still reachable through the dead parent's ppid link. Every other root has # to still be the incarnation we recorded. $survivors = @($descendantIds) + $cleanPassObserved = $false while ($true) { + # Never start another pass on an exhausted budget: the work below is + # itself unbounded platform I/O. + if ($budget.ElapsedMilliseconds -ge $TimeoutMs) { break } $newDescendantIds = @(Update-DescendantSnapshot ` -RootProcessIds (@($parentProcessId) + $survivors) ` -KnownProcessIds $descendantIds ` @@ -725,13 +793,22 @@ function Stop-HostNativeProcessTreeAndWait { -Reference $descendantIdentities[$descendantProcessId] ` -Current (& $IdentityProbeFn $descendantProcessId) }) - if ($survivors.Count -eq 0 -and $newDescendantIds.Count -eq 0) { break } + # The fixed point is a pass that found NOTHING new and NOTHING alive. + # An empty survivor set on a pass that still discovered a descendant is + # not a fixed point: whatever spawned it can spawn again. + if ($survivors.Count -eq 0 -and $newDescendantIds.Count -eq 0) { + $cleanPassObserved = $true + break + } if ($budget.ElapsedMilliseconds -ge $TimeoutMs) { break } & $SleepFn 50 } if ($survivors.Count -gt 0) { throw "Process tree for PID $($Process.Id) left descendant PID(s) $($survivors -join ', ') running after $TimeoutMs ms." } + if (-not $cleanPassObserved) { + throw "Process tree for PID $($Process.Id) could not be proven contained within $TimeoutMs ms: no containment pass completed with neither a surviving nor a newly discovered descendant." + } } # R5(2026-07-10 衛生輪 C3):kit-manager-api 納入 golden path——hybrid 模式下 coordinator diff --git a/scripts/lib/platform/platform-adapter.ps1 b/scripts/lib/platform/platform-adapter.ps1 index 8dfac3dd9..7a0016110 100644 --- a/scripts/lib/platform/platform-adapter.ps1 +++ b/scripts/lib/platform/platform-adapter.ps1 @@ -44,6 +44,26 @@ function Get-PlatformChildProcessIds { return @($children) } +function Test-OrphanRediscoverySupported { + # Can a dead parent's descendants still be found through the parent/child + # link AFTER the parent has exited? + # + # windows: YES. Win32_Process.ParentProcessId keeps the CREATOR's PID once + # the creator exits, so an orphan stays reachable from the parent + # PID we recorded - and while a handle to the exited process is + # held that PID cannot be recycled underneath the query. + # linux: NO. The kernel re-parents an orphan to init or to the nearest + # subreaper, so /proc//stat field 4 stops naming the creator + # and the link is gone for good. + # + # Anything that must PROVE containment across a parent exit on Linux has to + # own a boundary established at LAUNCH (process group / job object) or a + # descendant record captured before the exit - the same conclusion the + # converter containment work reached and measured (#489 / #509). + param([string] $Platform = (Get-PlatformName)) + return ($Platform -eq 'windows') +} + function Get-PlatformProcStatFields { # Parses /proc//stat. comm (field 2) may contain spaces/parens, so split # on the LAST ')' before reading positional fields. diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index 8bbb61fce..f84d1d784 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -794,45 +794,98 @@ time.sleep(120) Assert-True ($survivorStopwatch.Elapsed.TotalSeconds -lt 15) 'process-tree terminator bounds the descendant wait before failing closed' Write-TestPass 'process-tree terminator fails closed on a surviving descendant' - # Case 3 (#489 L1-COR-001): the parent exits BEFORE the helper is entered. - # Neither Windows nor Linux cascades termination, so the descendants are still - # running - the old `if ($Process.HasExited) { return }` reported containment - # without ever looking at them. An exited parent excuses the parent kill/wait - # only; the sweep, the termination and the proof still have to happen. + # Case 3 (#489 L1-COR-001, #513 gate r2 L1-COR-001): the parent exits BEFORE + # the helper is entered. Neither OS cascades termination, so the descendants + # are still running - the old `if ($Process.HasExited) { return }` reported + # containment without ever looking at them. + # + # Whether that is RECOVERABLE is a platform fact, so this drives the + # PRODUCTION DEFAULTS with no injected lookup and pins whichever branch this + # host is on: where the OS keeps the creator PID on an orphan the sweep must + # find and contain it; where the OS re-parents orphans there is nothing left + # to walk, and an empty discovery pass must fail closed instead of reading as + # containment. $orphanFixture = Join-Path $treeSandbox 'orphan-fixture.py' $orphanPidFile = Join-Path $treeSandbox 'orphan-pids.json' [System.IO.File]::WriteAllText($orphanFixture, $treeSource) $orphanProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $orphanFixture -PidPath $orphanPidFile $orphanPids = @(Get-Content -Raw -LiteralPath $orphanPidFile | ConvertFrom-Json) - $orphanParentId = [int]$orphanPids[0] $orphanChildId = [int]$orphanPids[1] $orphanProcess.Kill() [void]$orphanProcess.WaitForExit(5000) Assert-True $orphanProcess.HasExited 'orphan fixture parent has already exited before the helper is entered' Assert-True ($null -ne (Get-PlatformProcessIdentity -ProcessId $orphanChildId)) 'orphaned descendant outlives the parent that spawned it' + $orphanRediscoverable = Test-OrphanRediscoverySupported $orphanStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Sampled INSIDE the try, before the sandbox cleanup below: killing the child # here and asserting afterwards would let the test's own cleanup satisfy the # containment claim the helper is supposed to prove. $orphanChildIdentityAfterStop = 'never-sampled' + $orphanFailure = '' try { - Stop-HostNativeProcessTreeAndWait -Process $orphanProcess -TimeoutMs 5000 ` - -ChildPidLookup { - param($parentId) - # A re-parented descendant is no longer reachable through the dead - # parent's ppid link, so the caller's own record stands in for it. - if ([int]$parentId -eq $orphanParentId) { return @($orphanChildId) } - return @() - }.GetNewClosure() + Stop-HostNativeProcessTreeAndWait -Process $orphanProcess -TimeoutMs 5000 $orphanChildIdentityAfterStop = Get-PlatformProcessIdentity -ProcessId $orphanChildId } + catch { + $orphanFailure = $_.Exception.Message + } finally { $orphanStopwatch.Stop() Stop-Process -Id $orphanChildId -Force -ErrorAction SilentlyContinue } - Assert-True ($null -eq $orphanChildIdentityAfterStop) 'process-tree terminator contains the descendants of an already-exited parent' + if ($orphanRediscoverable) { + Assert-True ($null -eq $orphanChildIdentityAfterStop) 'production defaults contain the descendant of an already-exited parent where orphans stay rediscoverable' + } + else { + Assert-True ($orphanFailure -match 'not provable via PPID') 'production defaults fail closed for an already-exited parent where the platform re-parents orphans' + } Assert-True ($orphanStopwatch.Elapsed.TotalSeconds -lt 15) 'already-exited-parent containment stays inside the bounded window' - Write-TestPass 'process-tree terminator sweeps descendants when the parent exited before entry' + Write-TestPass 'production-default containment of an already-exited parent matches this platform''s orphan rediscovery' + + # Case 3b (#513 gate r2 L1-COR-001): drive BOTH sides of that platform gate + # from this one run by injecting the capability decision, so the POSIX + # fail-closed path is proven on Windows and the recorded-snapshot escape is + # proven on POSIX. Without a pre-exit record there is nothing to prove with. + $noRecordFixture = Join-Path $treeSandbox 'no-record-fixture.py' + $noRecordPidFile = Join-Path $treeSandbox 'no-record-pids.json' + [System.IO.File]::WriteAllText($noRecordFixture, $survivorSource) + $noRecordProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $noRecordFixture -PidPath $noRecordPidFile + $noRecordProcess.Kill() + [void]$noRecordProcess.WaitForExit(5000) + $noRecordFailure = '' + try { + Stop-HostNativeProcessTreeAndWait -Process $noRecordProcess -TimeoutMs 2000 ` + -OrphanRediscoveryProbeFn { $false } + } + catch { + $noRecordFailure = $_.Exception.Message + } + Assert-True ($noRecordFailure -match 'not provable via PPID') 'an already-exited parent on a re-parenting platform fails closed instead of reporting an empty pass as containment' + Assert-True ($noRecordFailure -match 'KnownDescendantProcessIds') 'the fail-closed message names the caller-side boundary that can prove containment' + Write-TestPass 'already-exited parent without a pre-exit record fails closed on a re-parenting platform' + + # ... and WITH that record the same platform contains and proves the real + # descendant, because the record replaces the PPID link discovery lost. + $recordFixture = Join-Path $treeSandbox 'record-fixture.py' + $recordPidFile = Join-Path $treeSandbox 'record-pids.json' + [System.IO.File]::WriteAllText($recordFixture, $treeSource) + $recordProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $recordFixture -PidPath $recordPidFile + $recordPids = @(Get-Content -Raw -LiteralPath $recordPidFile | ConvertFrom-Json) + $recordChildId = [int]$recordPids[1] + $recordProcess.Kill() + [void]$recordProcess.WaitForExit(5000) + $recordChildIdentityAfterStop = 'never-sampled' + try { + Stop-HostNativeProcessTreeAndWait -Process $recordProcess -TimeoutMs 5000 ` + -KnownDescendantProcessIds @($recordChildId) ` + -OrphanRediscoveryProbeFn { $false } + $recordChildIdentityAfterStop = Get-PlatformProcessIdentity -ProcessId $recordChildId + } + finally { + Stop-Process -Id $recordChildId -Force -ErrorAction SilentlyContinue + } + Assert-True ($null -eq $recordChildIdentityAfterStop) 'a pre-exit descendant record contains an already-exited parent tree on a re-parenting platform' + Write-TestPass 'pre-exit descendant record proves containment where PPID rediscovery cannot' # Case 4 (#489 L1-COR-001): the same entry state, but the descendant cannot be # killed. Silent success is exactly the defect; it must fail closed instead. @@ -845,6 +898,7 @@ time.sleep(120) $orphanFailFailure = '' try { Stop-HostNativeProcessTreeAndWait -Process $orphanFailProcess -TimeoutMs 1000 ` + -KnownDescendantProcessIds @($unkillableDescendantId) ` -ChildPidLookup { param($parentId) if ([int]$parentId -eq $unkillableDescendantId) { return @() } @@ -1062,6 +1116,118 @@ time.sleep(120) } Assert-True ($fallbackFailFailure -match "left descendant PID\(s\) $unkillableDescendantId running") 'forced fallback fails closed when a snapshotted descendant survives' Write-TestPass 'forced no-Kill(bool) fallback fails closed on a surviving descendant' + + # Case 9 (#513 gate r2 L1-SEC-001): reaching the deadline is NOT a clean pass. + # A pass that discovers a descendant and stops it leaves zero survivors, but + # whatever spawned it can spawn again, so the fixed point was never reached. + # Checking only `survivors > 0` after the loop reported success on exactly + # that state. Here every pass discovers one more descendant and successfully + # stops it, so the survivor set is always empty and a clean pass never + # happens - the helper must fail closed at the deadline. + $churnFixture = Join-Path $treeSandbox 'churn-fixture.py' + $churnPidFile = Join-Path $treeSandbox 'churn-pids.json' + [System.IO.File]::WriteAllText($churnFixture, $survivorSource) + $churnProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $churnFixture -PidPath $churnPidFile + $churnParentId = [int]$churnProcess.Id + $churnDiscovered = [System.Collections.Generic.List[int]]::new() + $churnStopped = [System.Collections.Generic.List[int]]::new() + $churnFailure = '' + $churnStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Stop-HostNativeProcessTreeAndWait -Process $churnProcess -TimeoutMs 700 ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -ne $churnParentId) { return @() } + $churnNextId = 500000 + $churnDiscovered.Count + $churnDiscovered.Add($churnNextId) + return @($churnNextId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if (-not $churnDiscovered.Contains([int]$procId)) { return $null } + if ($churnStopped.Contains([int]$procId)) { return $null } + return [pscustomobject]@{ + ProcessId = [int]$procId + BirthToken = 'churn-birth-token' + ExecutablePath = '' + CommandLine = '' + } + }.GetNewClosure() ` + -StopProcessFn { + param($procId) + $churnStopped.Add([int]$procId) + }.GetNewClosure() + } + catch { + $churnFailure = $_.Exception.Message + } + finally { + $churnStopwatch.Stop() + if (-not $churnProcess.HasExited) { + $churnProcess.Kill() + [void]$churnProcess.WaitForExit(5000) + } + } + Assert-True ($churnFailure -match 'could not be proven contained') 'reaching the deadline without a clean containment pass fails closed even with an empty survivor set' + Assert-True ($churnDiscovered.Count -ge 2) 'the containment loop kept re-enumerating until the deadline' + Assert-True ($churnStopped.Count -ge 1) 'every descendant discovered before the deadline was still terminated' + Assert-True ($churnStopwatch.Elapsed.TotalSeconds -lt 15) 'the no-clean-pass failure is still bounded' + Write-TestPass 'deadline without a clean containment pass fails closed' + + # Case 10 (#513 gate r2 L1-COR-002): TimeoutMs is ONE end-to-end budget. + # Discovery used to run before the stopwatch started and the parent wait still + # received the FULL TimeoutMs, so slow platform enumeration pushed the helper + # far past its advertised bound. Burn most of the budget inside discovery and + # assert the whole call still lands inside TimeoutMs rather than inside + # discovery + TimeoutMs (~1.5s under one budget, ~2.7s under two). + $budgetFixture = Join-Path $treeSandbox 'budget-fixture.py' + $budgetPidFile = Join-Path $treeSandbox 'budget-pids.json' + [System.IO.File]::WriteAllText($budgetFixture, $survivorSource) + $budgetProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $budgetFixture -PidPath $budgetPidFile + $budgetParentId = [int]$budgetProcess.Id + $budgetTimeoutMs = 1500 + $budgetDiscoveryDelayMs = 1200 + $budgetSurvivorId = 424245 + $budgetIdentity = [pscustomobject]@{ + ProcessId = $budgetSurvivorId + BirthToken = 'budget-birth-token' + ExecutablePath = '' + CommandLine = '' + } + $budgetLookups = [System.Collections.Generic.List[int]]::new() + $budgetFailure = '' + $budgetStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Stop-HostNativeProcessTreeAndWait -Process $budgetProcess -TimeoutMs $budgetTimeoutMs ` + -ChildPidLookup { + param($parentId) + if ([int]$parentId -ne $budgetParentId) { return @() } + $budgetLookups.Add([int]$parentId) + # Only the first (snapshot) enumeration is slow, so the cost lands + # squarely in the window the old code left unmetered. + if ($budgetLookups.Count -eq 1) { Start-Sleep -Milliseconds $budgetDiscoveryDelayMs } + return @($budgetSurvivorId) + }.GetNewClosure() ` + -IdentityProbeFn { + param($procId) + if ([int]$procId -eq $budgetSurvivorId) { return $budgetIdentity } + return $null + }.GetNewClosure() ` + -StopProcessFn { param($procId) } | Out-Null + } + catch { + $budgetFailure = $_.Exception.Message + } + finally { + $budgetStopwatch.Stop() + if (-not $budgetProcess.HasExited) { + $budgetProcess.Kill() + [void]$budgetProcess.WaitForExit(5000) + } + } + Assert-True ($budgetFailure -match "left descendant PID\(s\) $budgetSurvivorId running") 'a slow-discovery run still fails closed on its surviving descendant' + Assert-True ($budgetStopwatch.Elapsed.TotalMilliseconds -lt ($budgetTimeoutMs + 500)) 'discovery time counts against TimeoutMs instead of being added to it' + Write-TestPass 'TimeoutMs is a single end-to-end containment budget' } finally { Remove-TestSandbox -Path $treeSandbox From 834ef5d83223d2d5f233d2969ccab03a905f0b3c Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:53:17 +0800 Subject: [PATCH 07/12] docs(evidence): record the round 3 containment fixes and their measured basis Binds the second ship-gate round to its reviewed head d634530 with the clean-worktree result observed at it, and records what was actually measured rather than argued: - Windows orphan rediscovery is SUPPORTED. A killed fixture parent's PID still resolved its orphaned python child and its conhost through Win32_Process.ParentProcessId, and the production-default sweep contained and proved both. That is the platform fact Test-OrphanRediscoverySupported encodes, and the reason the POSIX branch has to fail closed instead. - Both behavioural regressions were reproduced against the previous implementation before fixing: the clean-pass scenario returned SUCCESS after discovering twelve descendants, and the slow-discovery scenario spent 2786 ms against an advertised 1500 ms bound. The POSIX branch of the platform gate is proven on this host through the injected capability decision only; no canonical Linux session was opened, so it has never run on a real re-parenting kernel. That is recorded as an explicit NOT_RUN rather than folded into the PASS list. Refs #489. Co-Authored-By: Claude Fable 5 --- .../self-referential-bootstrap/README.md | 8 +++++++- .../self-referential-bootstrap/verification.txt | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md index 31c6366ce..ca247755a 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md @@ -5,7 +5,7 @@ - `stack_kind=self_referential_bootstrap` - Pull request: see the ledger entry `mechanism-hardening-2` (`pr` field is the binding record) - Baseline: freshly fetched `origin/main` at `472192386f8402cf19a29005daf25556d26f222c` -- Reviewed head: `110c657fd620e3bdbac4379ac716da99d37848b9` (round 2), worktree clean — `git status --porcelain` produced no output at that commit +- Reviewed head: `110c657fd620e3bdbac4379ac716da99d37848b9` (round 2) and `d63453024f59b13714ed222ea14bdc34a2890253` (round 3), worktree clean at each — `git status --porcelain` produced no output at either commit - This is isolated branch bootstrap evidence. It is not canonical post-change evidence and does not claim full-system E2E completion. ## Scope @@ -32,6 +32,12 @@ Local mechanism suites on the branch head, on Windows with PowerShell 7.5.4. The The Codex tri-adversarial ship-gate returned NO-SHIP on four findings against `Stop-HostNativeProcessTreeAndWait`, and the PR review threads named the same defects. All four are closed at the reviewed head: the pre-entry `HasExited` return no longer skips descendant containment, descendant stops are identity-revalidated against PID reuse, containment is a bounded re-enumerating fixed point rather than one snapshot, and the tree-kill capability decision is injectable so the Windows PowerShell 5.1 fallback is exercised as behaviour. The round also restored `-DryRun` adjudication of `KIT_CONTROL_URL` and put `test-host-native-launcher.ps1` into the required `rebuild-test-deploy` CI job, which is why `.github/workflows/ci.yml` and `scripts/verification-manifest.json` joined this entry's `verification_mechanism_paths`. +## Round 3: PR #513 ship-gate second pass + +A second gate pass found three more defects in the same helper, all real. The exited-parent sweep added in round 2 is only sound where the OS keeps the creator PID on an orphan — measured as true on Windows and false on Linux, where the kernel re-parents orphans — so that platform fact now lives in `Test-OrphanRediscoverySupported` and the helper fails closed where PPID rediscovery cannot prove containment, naming the caller as the authoritative boundary and accepting a pre-exit descendant record as the escape. Reaching the deadline is no longer treated as a clean containment pass. `TimeoutMs` is now one end-to-end budget spanning discovery, termination, the parent wait, and every containment pass. + +Both behavioural regressions were confirmed against the previous implementation before the fix, not merely argued: the clean-pass scenario returned success after discovering twelve descendants, and the slow-discovery scenario overran its advertised bound by 1286 ms. The POSIX branch of the platform gate is proven on this host through the injected capability decision; it has not been executed on a real re-parenting kernel, which is recorded as a limit in `verification.txt`. + ## Limits - No canonical Linux rebuild and no canonical deployment verification were executed for this bundle. Both are recorded in the entry's verification contract and are owed at fixpoint. diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt index 48dbbac6a..e50d81afa 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt @@ -42,5 +42,22 @@ PASS test-rebuild-test-deploy PASS test-agent-governance-check PASS test-deploy-dryrun (pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-dryrun.ps1) NOT_RUN the remaining round-1 command ids were not re-executed in round 2; nothing in the round-2 scope reaches them +--- round 3: PR #513 ship-gate second pass (L1-COR-001 platform gate, L1-SEC-001 clean pass, L1-COR-002 single budget) --- +reviewed_head_commit=d63453024f59b13714ed222ea14bdc34a2890253 +reviewed_head_worktree=clean (`git status --porcelain` produced no output at the recorded commit) +reviewed_host=windows pwsh=7.5.4 +reviewed_scope=scripts/lib/host-native-launcher.ps1, scripts/lib/platform/platform-adapter.ps1, scripts/tests/test-host-native-launcher.ps1 +PASS test-host-native-launcher +PASS test-deploy-governance-static +PASS test-verify-all +PASS test-self-referential-bootstrap +PASS invoke-powershell-static +PASS test-platform-adapter +PASS test-host-native-child-launch +PASS test-rebuild-test-deploy +MEASURED windows-orphan-rediscovery=SUPPORTED (a killed fixture parent's PID still resolved its orphaned python child and its conhost through Win32_Process.ParentProcessId, and the production-default sweep contained and proved both; this is the platform fact Test-OrphanRediscoverySupported encodes) +MEASURED regression-is-real (previous implementation at 26f056d, same two scenarios): the clean-pass scenario returned SUCCESS after discovering 12 descendants with an empty survivor set, and the slow-discovery scenario spent 2786 ms against an advertised 1500 ms bound +NOT_RUN linux-leg-of-the-platform-gate (no canonical Linux session was opened; the POSIX fail-closed branch is proven on this host through the injected capability gate, not on a real re-parenting kernel) +NOT_RUN the remaining round-1 command ids were not re-executed in round 3; nothing in the round-3 scope reaches them LIMIT canonical-post-change-verification=pending merge and mainline fixpoint rebuild LIMIT full-system-e2e=not claimed From 3044e788ae2a741ea1f877f3353874cc31d606ad Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:19:07 +0800 Subject: [PATCH 08/12] fix(launcher): narrow the process-tree helper's claim to what it can prove Three ship-gate rounds all landed HIGH findings on the same thing: the helper was documented and used as if it delivered inescapable containment, while every mechanism it has rides the OS parent/child link, which is advisory. Patching the next escape window would have invited a fourth. This narrows the claim instead. The contract is now stated in the function itself: a bounded best-effort SWEEP with a fail-closed provability report. It enumerates what it can reach, terminates deepest-first with identity revalidation, re-enumerates to a fixed point, and throws whenever it cannot prove the set it knows about is gone. It is explicitly NOT an escape-proof boundary - only an OS boundary established at LAUNCH (Windows Job Object, POSIX process group / cgroup) can be that, which is #517 and the Start-HostNativeService follow-up. A caller gets "this sweep proved what it could see, or it threw", never "nothing survived". Removed -KnownDescendantProcessIds. It was the escape hatch that let the helper keep claiming provable containment on a re-parenting platform, neither production caller passed it, and its existence blurred exactly the line this round is drawing. The POSIX already-exited-parent path stays a fail-closed throw, and the message now states the narrowed contract and names the launch- time boundary and #517 rather than offering a parameter as the answer. The gate's remaining HIGH - a descendant spawned between enumeration and the stop that follows it - is REFUTED by measurement, not argument. A real three-level fixture whose grandchild is hidden from the snapshot (running the whole time, so it stands in for one spawned a moment after enumeration), with the tree-kill capability forced off so .NET cannot do the containment for us: the helper stopped the snapshot, killed the parent last, and pass 1 re-walked every snapshot member it had just killed, rediscovered the grandchild through its dead parent's link, stopped it, and pass 2 came back clean. All three PIDs gone, no throw. That is now a regression test. The lookup sequence it asserts on is the mechanism: [P, D, P, D, conhost, G, P]. The residual that survives that refutation is documented rather than papered over, because closing it would make things worse: a descendant discovered AND stopped inside one pass drops out of the expansion roots, so a child it spawned in that sub-window is not rediscovered. Expanding from dead PIDs every pass would trade this fail-open gap for a fail-dangerous one - a recycled PID would contribute an unrelated process's children to the kill set. Also: the orphan-rediscovery test no longer asks Test-OrphanRediscoverySupported which branch to expect, which was the implementation grading its own homework. It reads the raw OS record instead - Win32_Process.ParentProcessId on Windows, /proc//stat field 4 on Linux - and asserts the helper agrees with it. The gated deployment static test's three pinned literals are unchanged. Refs #489, #517. Co-Authored-By: Claude Fable 5 --- scripts/lib/host-native-launcher.ps1 | 83 +++++++++------ scripts/tests/test-host-native-launcher.ps1 | 108 ++++++++++++++------ 2 files changed, 129 insertions(+), 62 deletions(-) diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 9023c0a59..886faf98e 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -523,11 +523,29 @@ function Start-HostNativeGovernance { } function Stop-HostNativeProcessTreeAndWait { - # Bounded, fail-closed process-tree terminator. The CAD hardener and the Kit - # Manager import probe rely on it to PROVE that a timed-out tree is gone - # before releasing the trust boundary they hold. + # Bounded best-effort process-tree SWEEP with a fail-closed provability + # report. The contract is deliberately narrower than "containment", and + # callers must read it as written: # - # Two defects made that proof false (#489 L1-COR-004): + # It DOES: enumerate the descendants it can reach, terminate them + # deepest-first (parent last) with identity revalidation before every + # stop, re-enumerate to a fixed point, and THROW whenever it cannot prove + # that the set it knows about is gone inside one bounded budget. + # + # It is NOT an escape-proof boundary. Discovery rides the OS parent/child + # link, and that link is advisory: a live process can spawn a child in the + # window between an enumeration and the stop that follows it, and on a + # platform that re-parents orphans the link vanishes outright once the + # parent dies. Only an OS boundary established at LAUNCH - a Windows Job + # Object, or a POSIX process group / cgroup - makes containment + # inescapable (#517, and the follow-up that moves Start-HostNativeService + # onto one). + # + # So a caller gets "this sweep proved what it could see, or it threw" - never + # "nothing survived". Releasing a trust boundary on the strength of a silent + # return is a misuse of this helper. + # + # Two defects made even that narrower proof false (#489 L1-COR-004): # 1. Kill($true), WaitForExit and HasExited all describe the SAME Process # object. Microsoft documents that they can report completion while # descendants are still running, and the old catch additionally swallowed @@ -551,9 +569,9 @@ function Stop-HostNativeProcessTreeAndWait { # 6. Sweeping an already-exited parent is only possible where the OS keeps # the creator PID on an orphan (#513 gate r2 L1-COR-001). Linux # re-parents orphans to init/subreaper, so BOTH discovery passes come - # back empty and an empty pass would read as containment. There, a - # pre-exit descendant record is the only proof, and without one this - # fails closed instead of succeeding. + # back empty and an empty pass would read as containment. There this + # fails closed and names the caller's launch-time boundary instead of + # pretending a sweep can recover the link. # 7. Reaching the deadline is not a clean pass (#513 gate r2 L1-SEC-001). # Success requires a pass that observed NEITHER a survivor NOR a newly # discovered descendant; an empty survivor set at the deadline is not @@ -577,12 +595,6 @@ function Stop-HostNativeProcessTreeAndWait { param( [Parameter(Mandatory = $true)][System.Diagnostics.Process] $Process, [ValidateRange(1, 60000)][int] $TimeoutMs = 5000, - # A descendant record captured BEFORE the parent could exit. On a platform - # that re-parents orphans this is the only thing that can prove containment - # for a parent that was already gone on entry, because the PPID link that - # discovery walks no longer names the creator. Callers that hold an OS - # boundary (process group / job) do not need it. - [AllowEmptyCollection()][int[]] $KnownDescendantProcessIds = @(), # Injectable so the containment postcondition is testable without an # actually unkillable process. [scriptblock] $ChildPidLookup = { @@ -697,34 +709,23 @@ function Stop-HostNativeProcessTreeAndWait { $parentProcessId = [int]$Process.Id $parentAlreadyExited = $Process.HasExited - $seededDescendantIds = @(@($KnownDescendantProcessIds) | - ForEach-Object { [int]$_ } | - Where-Object { $_ -ne $parentProcessId } | - Select-Object -Unique) # An already-exited parent is only sweepable where the OS keeps the creator # PID on an orphan. Where it does not, discovery has nothing left to walk, so - # an empty result is ignorance rather than containment - fail closed and say - # where the authoritative boundary actually is. - if ($parentAlreadyExited -and - $seededDescendantIds.Count -eq 0 -and - -not [bool](& $OrphanRediscoveryProbeFn)) { + # an empty result is ignorance rather than proof - fail closed and name the + # boundary that could actually have prevented the escape. + if ($parentAlreadyExited -and -not [bool](& $OrphanRediscoveryProbeFn)) { throw ("Process tree for PID $parentProcessId cannot be proven contained: the parent had " + 'already exited on entry and this platform re-parents orphans, so containment is not ' + - 'provable via PPID here. The authoritative containment boundary is the caller - hold an ' + - 'OS process group or job established at launch, or capture the descendant set before the ' + - 'parent can exit and pass it as -KnownDescendantProcessIds.') + 'provable via PPID here. This helper is a bounded best-effort sweep, not an escape-proof ' + + 'boundary: the authoritative containment boundary is the caller, which must hold an OS ' + + 'process group, cgroup or Job Object established at launch (#517).') } $descendantIdentities = @{} - foreach ($seededId in $seededDescendantIds) { - $descendantIdentities[$seededId] = (& $IdentityProbeFn $seededId) - } - # Seeded ids first, discovered ones after: reverse iteration then stops the - # deepest-discovered members before the caller's recorded direct children. - $descendantIds = @($seededDescendantIds) + @(Update-DescendantSnapshot ` - -RootProcessIds (@($parentProcessId) + $seededDescendantIds) ` - -KnownProcessIds $seededDescendantIds ` + $descendantIds = @(Update-DescendantSnapshot ` + -RootProcessIds @($parentProcessId) ` + -KnownProcessIds @() ` -Identities $descendantIdentities ` -LookupFn $ChildPidLookup ` -ProbeFn $IdentityProbeFn) @@ -769,6 +770,22 @@ function Stop-HostNativeProcessTreeAndWait { # cannot be recycled underneath us, and on Windows a just-orphaned child is # still reachable through the dead parent's ppid link. Every other root has # to still be the incarnation we recorded. + # + # $survivors starts as the WHOLE snapshot, alive or not, so the first pass + # re-walks every member the stop above just killed. That is what catches a + # descendant spawned between enumeration and the stop: measured on Windows + # with a real three-level fixture whose grandchild was hidden from the + # snapshot, pass 1 rediscovered it through its dead parent's link, stopped + # it, and pass 2 came back clean. + # + # KNOWN RESIDUAL (analysis, not measured; the reason the contract above stops + # short of "containment"): a descendant discovered AND stopped inside the same + # pass drops out of $survivors, so the next pass no longer expands from it. A + # child it spawned in that sub-window is not rediscovered. Expanding from dead + # PIDs on every pass instead would trade this fail-open gap for a + # fail-dangerous one - a recycled PID would contribute an unrelated process's + # children to the kill set - so the gap is documented and left to the + # launch-time OS boundary in #517 rather than papered over here. $survivors = @($descendantIds) $cleanPassObserved = $false while ($true) { diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index f84d1d784..35cdeca37 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -810,12 +810,32 @@ time.sleep(120) [System.IO.File]::WriteAllText($orphanFixture, $treeSource) $orphanProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $orphanFixture -PidPath $orphanPidFile $orphanPids = @(Get-Content -Raw -LiteralPath $orphanPidFile | ConvertFrom-Json) + $orphanParentId = [int]$orphanPids[0] $orphanChildId = [int]$orphanPids[1] $orphanProcess.Kill() [void]$orphanProcess.WaitForExit(5000) Assert-True $orphanProcess.HasExited 'orphan fixture parent has already exited before the helper is entered' Assert-True ($null -ne (Get-PlatformProcessIdentity -ProcessId $orphanChildId)) 'orphaned descendant outlives the parent that spawned it' - $orphanRediscoverable = Test-OrphanRediscoverySupported + # INDEPENDENT oracle. Asking Test-OrphanRediscoverySupported which branch to + # expect would make the implementation grade its own homework, so this reads + # the raw OS record instead: Win32_Process.ParentProcessId on Windows, and + # /proc//stat field 4 on Linux, neither routed through the launcher or + # the platform adapter. + $orphanRediscoverable = $false + if ($IsWindows) { + $orphanCimRow = Get-CimInstance Win32_Process -Filter "ProcessId=$orphanChildId" -ErrorAction SilentlyContinue + $orphanRediscoverable = ($null -ne $orphanCimRow -and [int]$orphanCimRow.ParentProcessId -eq $orphanParentId) + } + else { + $orphanStatPath = "/proc/$orphanChildId/stat" + if (Test-Path -LiteralPath $orphanStatPath) { + $orphanStatRaw = Get-Content -LiteralPath $orphanStatPath -Raw -ErrorAction SilentlyContinue + if (-not [string]::IsNullOrWhiteSpace($orphanStatRaw)) { + $orphanStatFields = $orphanStatRaw.Substring($orphanStatRaw.LastIndexOf(')') + 1).Trim() -split '\s+' + $orphanRediscoverable = ([int]$orphanStatFields[1] -eq $orphanParentId) + } + } + } $orphanStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Sampled INSIDE the try, before the sandbox cleanup below: killing the child # here and asserting afterwards would let the test's own cleanup satisfy the @@ -834,10 +854,10 @@ time.sleep(120) Stop-Process -Id $orphanChildId -Force -ErrorAction SilentlyContinue } if ($orphanRediscoverable) { - Assert-True ($null -eq $orphanChildIdentityAfterStop) 'production defaults contain the descendant of an already-exited parent where orphans stay rediscoverable' + Assert-True ($null -eq $orphanChildIdentityAfterStop) 'production defaults sweep the descendant of an already-exited parent where the raw OS record still links it' } else { - Assert-True ($orphanFailure -match 'not provable via PPID') 'production defaults fail closed for an already-exited parent where the platform re-parents orphans' + Assert-True ($orphanFailure -match 'not provable via PPID') 'production defaults fail closed for an already-exited parent where the raw OS record no longer links it' } Assert-True ($orphanStopwatch.Elapsed.TotalSeconds -lt 15) 'already-exited-parent containment stays inside the bounded window' Write-TestPass 'production-default containment of an already-exited parent matches this platform''s orphan rediscovery' @@ -861,31 +881,10 @@ time.sleep(120) $noRecordFailure = $_.Exception.Message } Assert-True ($noRecordFailure -match 'not provable via PPID') 'an already-exited parent on a re-parenting platform fails closed instead of reporting an empty pass as containment' - Assert-True ($noRecordFailure -match 'KnownDescendantProcessIds') 'the fail-closed message names the caller-side boundary that can prove containment' - Write-TestPass 'already-exited parent without a pre-exit record fails closed on a re-parenting platform' - - # ... and WITH that record the same platform contains and proves the real - # descendant, because the record replaces the PPID link discovery lost. - $recordFixture = Join-Path $treeSandbox 'record-fixture.py' - $recordPidFile = Join-Path $treeSandbox 'record-pids.json' - [System.IO.File]::WriteAllText($recordFixture, $treeSource) - $recordProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $recordFixture -PidPath $recordPidFile - $recordPids = @(Get-Content -Raw -LiteralPath $recordPidFile | ConvertFrom-Json) - $recordChildId = [int]$recordPids[1] - $recordProcess.Kill() - [void]$recordProcess.WaitForExit(5000) - $recordChildIdentityAfterStop = 'never-sampled' - try { - Stop-HostNativeProcessTreeAndWait -Process $recordProcess -TimeoutMs 5000 ` - -KnownDescendantProcessIds @($recordChildId) ` - -OrphanRediscoveryProbeFn { $false } - $recordChildIdentityAfterStop = Get-PlatformProcessIdentity -ProcessId $recordChildId - } - finally { - Stop-Process -Id $recordChildId -Force -ErrorAction SilentlyContinue - } - Assert-True ($null -eq $recordChildIdentityAfterStop) 'a pre-exit descendant record contains an already-exited parent tree on a re-parenting platform' - Write-TestPass 'pre-exit descendant record proves containment where PPID rediscovery cannot' + Assert-True ($noRecordFailure -match 'bounded best-effort sweep, not an escape-proof boundary') 'the fail-closed message states the narrowed contract rather than implying the sweep could have contained it' + Assert-True ($noRecordFailure -match 'established at launch') 'the fail-closed message names the launch-time OS boundary as the authoritative one' + Assert-True ($noRecordFailure -match '#517') 'the fail-closed message cites the tracked launch-time containment work' + Write-TestPass 'already-exited parent on a re-parenting platform fails closed and points at the launch-time boundary' # Case 4 (#489 L1-COR-001): the same entry state, but the descendant cannot be # killed. Silent success is exactly the defect; it must fail closed instead. @@ -898,7 +897,7 @@ time.sleep(120) $orphanFailFailure = '' try { Stop-HostNativeProcessTreeAndWait -Process $orphanFailProcess -TimeoutMs 1000 ` - -KnownDescendantProcessIds @($unkillableDescendantId) ` + -OrphanRediscoveryProbeFn { $true } ` -ChildPidLookup { param($parentId) if ([int]$parentId -eq $unkillableDescendantId) { return @() } @@ -1228,6 +1227,57 @@ time.sleep(120) Assert-True ($budgetFailure -match "left descendant PID\(s\) $budgetSurvivorId running") 'a slow-discovery run still fails closed on its surviving descendant' Assert-True ($budgetStopwatch.Elapsed.TotalMilliseconds -lt ($budgetTimeoutMs + 500)) 'discovery time counts against TimeoutMs instead of being added to it' Write-TestPass 'TimeoutMs is a single end-to-end containment budget' + + # Case 11 (#513 gate r3 HIGH-2, REFUTED by this fixture): a descendant that + # appears between the enumeration and the stop that follows it is claimed to + # escape. It does not, because $survivors starts as the WHOLE snapshot, so the + # first containment pass re-walks every member the stop just killed and finds + # what hung off them. + # + # A REAL three-level chain, with the grandchild hidden from the snapshot only + # - it is running the whole time, exactly like one spawned a moment after + # enumeration. The tree-kill capability is forced off so .NET's own recursive + # kill cannot do the containment for us and mask the helper's logic. + $escapeeFixture = Join-Path $treeSandbox 'escapee-fixture.py' + $escapeePidFile = Join-Path $treeSandbox 'escapee-pids.json' + [System.IO.File]::WriteAllText($escapeeFixture, $chainSource) + $escapeeProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $escapeeFixture -PidPath $escapeePidFile + $escapeePids = @(Get-Content -Raw -LiteralPath $escapeePidFile | ConvertFrom-Json) + Assert-Equal 3 $escapeePids.Count 'escapee fixture records a three-level parent/child/grandchild chain' + $escapeeParentId = [int]$escapeePids[0] + $escapeeChildId = [int]$escapeePids[1] + $escapeeGrandchildId = [int]$escapeePids[2] + $escapeeLookups = [System.Collections.Generic.List[int]]::new() + $escapeeGrandchildAfterStop = 'never-sampled' + $escapeeFailure = '' + try { + Stop-HostNativeProcessTreeAndWait -Process $escapeeProcess -TimeoutMs 5000 ` + -TreeKillCapabilityProbeFn { $false } ` + -ChildPidLookup { + param($parentId) + $escapeeLookups.Add([int]$parentId) + # Calls 1-2 are the initial snapshot: report the child only, so the + # grandchild is outside the set the stop below operates on. + if ($escapeeLookups.Count -le 2) { + if ([int]$parentId -eq $escapeeParentId) { return @($escapeeChildId) } + return @() + } + return @(Get-PlatformChildProcessIds -ParentProcessId ([int]$parentId)) + }.GetNewClosure() + $escapeeGrandchildAfterStop = Get-PlatformProcessIdentity -ProcessId $escapeeGrandchildId + } + catch { + $escapeeFailure = $_.Exception.Message + } + finally { + foreach ($escapeePid in $escapeePids) { + Stop-Process -Id ([int]$escapeePid) -Force -ErrorAction SilentlyContinue + } + } + Assert-Equal '' $escapeeFailure 'the re-enumerating fixed point contains the post-enumeration descendant instead of failing closed on it' + Assert-True ($null -eq $escapeeGrandchildAfterStop) 'a descendant missed by the snapshot is still discovered, stopped and proven gone' + Assert-True ($escapeeLookups.Count -gt 2) 'containment re-enumerated past the snapshot that missed it' + Write-TestPass 'a descendant appearing after enumeration is caught by the clean-pass fixed point' } finally { Remove-TestSandbox -Path $treeSandbox From 8fd3ca2da1dc93a56fe9fc2a093639f8f39cedc6 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:22:07 +0800 Subject: [PATCH 09/12] docs(evidence): record round 4, the HIGH-2 refutation and the residual it leaves Binds the narrowed-claim round to its reviewed head 3044e78 with the clean worktree observed at it, and keeps the three kinds of statement apart instead of flattening them into one PASS list: - MEASURED: a descendant hidden from the snapshot but running throughout was rediscovered, stopped and proven gone by the re-enumerating fixed point, with the tree kill forced off so .NET could not do the work. That refutes the gate's remaining HIGH and is now a regression case. - ANALYSIS-NOT-MEASURED: the sub-window that survives it - a descendant discovered and stopped inside one pass leaves the expansion roots, so a child it spawned in that window is not rediscovered. Left open deliberately, because expanding from dead PIDs would trade a fail-open gap for a fail-dangerous one. - CONTRACT: the helper is a bounded best-effort sweep with a fail-closed provability report, not an escape-proof boundary, and -KnownDescendantProcessIds is gone. The POSIX leg of the platform gate is still NOT_RUN on a real re-parenting kernel; it remains proven only through the injected capability decision. Refs #489, #517. Co-Authored-By: Claude Fable 5 --- .../self-referential-bootstrap/README.md | 10 +++++++++- .../verification.txt | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md index ca247755a..17d4092a2 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md @@ -5,7 +5,7 @@ - `stack_kind=self_referential_bootstrap` - Pull request: see the ledger entry `mechanism-hardening-2` (`pr` field is the binding record) - Baseline: freshly fetched `origin/main` at `472192386f8402cf19a29005daf25556d26f222c` -- Reviewed head: `110c657fd620e3bdbac4379ac716da99d37848b9` (round 2) and `d63453024f59b13714ed222ea14bdc34a2890253` (round 3), worktree clean at each — `git status --porcelain` produced no output at either commit +- Reviewed head: `110c657fd620e3bdbac4379ac716da99d37848b9` (round 2), `d63453024f59b13714ed222ea14bdc34a2890253` (round 3) and `3044e788ae2a741ea1f877f3353874cc31d606ad` (round 4), worktree clean at each — `git status --porcelain` produced no output at any of them - This is isolated branch bootstrap evidence. It is not canonical post-change evidence and does not claim full-system E2E completion. ## Scope @@ -38,6 +38,14 @@ A second gate pass found three more defects in the same helper, all real. The ex Both behavioural regressions were confirmed against the previous implementation before the fix, not merely argued: the clean-pass scenario returned success after discovering twelve descendants, and the slow-discovery scenario overran its advertised bound by 1286 ms. The POSIX branch of the platform gate is proven on this host through the injected capability decision; it has not been executed on a real re-parenting kernel, which is recorded as a limit in `verification.txt`. +## Round 4: narrowing the claim instead of chasing the next window + +Three gate rounds each produced a HIGH against the same helper, and each one was a different way of saying that a PPID-based sweep cannot deliver inescapable containment. Round 4 stops patching windows and fixes the claim: `Stop-HostNativeProcessTreeAndWait` is now documented, messaged and tested as a **bounded best-effort sweep with a fail-closed provability report** — never "nothing survived". Inescapable containment needs an OS boundary established at launch, which is tracked separately in [#517](https://github.com/monkey1sai/AI-BIM-governance/issues/517) and its `Start-HostNativeService` follow-up. + +The `-KnownDescendantProcessIds` escape hatch was removed: it existed only to let the helper keep claiming provable containment on a re-parenting platform, and neither production caller used it. + +The round's remaining HIGH — a descendant appearing between enumeration and the following stop — was **refuted by measurement**, and that measurement is now a regression case. One residual survives it and is documented rather than closed, because closing it would trade a fail-open gap for a fail-dangerous one; see `verification.txt` for both. + ## Limits - No canonical Linux rebuild and no canonical deployment verification were executed for this bundle. Both are recorded in the entry's verification contract and are owed at fixpoint. diff --git a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt index e50d81afa..8038c81d3 100644 --- a/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt @@ -59,5 +59,23 @@ MEASURED windows-orphan-rediscovery=SUPPORTED (a killed fixture parent's PID sti MEASURED regression-is-real (previous implementation at 26f056d, same two scenarios): the clean-pass scenario returned SUCCESS after discovering 12 descendants with an empty survivor set, and the slow-discovery scenario spent 2786 ms against an advertised 1500 ms bound NOT_RUN linux-leg-of-the-platform-gate (no canonical Linux session was opened; the POSIX fail-closed branch is proven on this host through the injected capability gate, not on a real re-parenting kernel) NOT_RUN the remaining round-1 command ids were not re-executed in round 3; nothing in the round-3 scope reaches them +--- round 4: PR #513 ship-gate third pass, resolved by NARROWING the helper's claim --- +reviewed_head_commit=3044e788ae2a741ea1f877f3353874cc31d606ad +reviewed_head_worktree=clean (`git status --porcelain` produced no output at the recorded commit) +reviewed_host=windows pwsh=7.5.4 +reviewed_scope=scripts/lib/host-native-launcher.ps1, scripts/tests/test-host-native-launcher.ps1 +PASS test-host-native-launcher +PASS test-deploy-governance-static +PASS test-verify-all +PASS test-self-referential-bootstrap +PASS invoke-powershell-static +PASS test-platform-adapter +PASS test-host-native-child-launch +PASS test-rebuild-test-deploy +MEASURED post-enumeration-descendant=CONTAINED (gate HIGH-2 refuted). Real three-level chain, grandchild hidden from the snapshot only and running throughout, tree-kill capability forced off so .NET's recursive kill could not mask the helper's own logic. Containment pass 1 re-walked every snapshot member the stop had just killed, rediscovered the grandchild through its dead parent's link and stopped it; pass 2 returned clean. Parent, child and grandchild all gone, helper returned success, lookup sequence [P, D, P, D, conhost, G, P]. Now pinned as a regression case. +ANALYSIS-NOT-MEASURED residual-sub-window: a descendant discovered AND stopped inside one pass drops out of the expansion roots, so a child it spawned between that pass's enumeration and its stop is not rediscovered. Not closed on purpose - expanding from dead PIDs on every pass would trade this fail-open gap for a fail-dangerous one (a recycled PID would contribute an unrelated process's children to the kill set). Owned by the launch-time OS boundary follow-up, #517. +CONTRACT the helper is now documented and tested as a bounded best-effort sweep with a fail-closed provability report, NOT an escape-proof boundary; -KnownDescendantProcessIds was removed because it let the helper keep claiming provable containment on a re-parenting platform and neither production caller used it +NOT_RUN linux-leg-of-the-platform-gate (still no canonical Linux session; the POSIX fail-closed branch remains proven only through the injected capability gate on this host) +NOT_RUN the remaining round-1 command ids were not re-executed in round 4; nothing in the round-4 scope reaches them LIMIT canonical-post-change-verification=pending merge and mainline fixpoint rebuild LIMIT full-system-e2e=not claimed From f5078d98e8b32a4566714a4cd0571b6071bae687 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:42:15 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(env):=20=E8=A3=9C=E4=B8=8A=20Kit=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E7=B6=B2=E5=9D=80=E6=A8=A3=E6=9D=BF=E4=BD=94?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.web-plane.host-kit.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.web-plane.host-kit.example b/.env.web-plane.host-kit.example index d574991a0..b2a5b941f 100644 --- a/.env.web-plane.host-kit.example +++ b/.env.web-plane.host-kit.example @@ -44,6 +44,7 @@ HOST_CONVERSION_API_BASE=http://host.docker.internal:49101 KIT_SIGNALING_PORT=49100 # KIT_MEDIA_HOST=192.168.10.105 KIT_MEDIA_PORT=47998 +KIT_CONTROL_URL= # Same-Kit spectator streams. Default gives 5 spectator viewer slots in addition # to the primary stream. Set KIT_SPECTATOR_COUNT=4 if the desired total is 5 viewers. From 8aee337d6e58d5dcb280b5be2a0f8cda4368d664 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:47:30 +0800 Subject: [PATCH 11/12] chore(governance): declare platform-adapter.ps1 in the mechanism-hardening-2 entry Round-3 added Test-OrphanRediscoverySupported to scripts/lib/platform/platform-adapter.ps1, which is a classified verification-mechanism path; the bundle's ledger entry must declare every mechanism path its diff touches. test-self-referential-bootstrap all green. Refs #489 Co-Authored-By: Claude Fable 5 --- scripts/self-referential-bootstrap-ledger.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/self-referential-bootstrap-ledger.json b/scripts/self-referential-bootstrap-ledger.json index bf87c2412..5ec5e5ffd 100644 --- a/scripts/self-referential-bootstrap-ledger.json +++ b/scripts/self-referential-bootstrap-ledger.json @@ -157,6 +157,7 @@ ".github/workflows/ci.yml", "scripts/deploy.ps1", "scripts/lib/host-native-launcher.ps1", + "scripts/lib/platform/platform-adapter.ps1", "scripts/self-referential-bootstrap-ledger.json", "scripts/verification-manifest.json", "scripts/verify-all.ps1" From e4cd51db4f1f6d88c6aa0b1d73b8bb29b7e205a4 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:55:41 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(deploy):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=98=8E=E7=A4=BA=E7=92=B0=E5=A2=83=E6=AA=94=E4=B8=A6=E5=B0=81?= =?UTF-8?q?=E9=96=89=E5=AD=90=E7=A8=8B=E5=BA=8F=E5=88=97=E8=88=89=E5=A4=B1?= =?UTF-8?q?=E6=95=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.ps1 | 10 ++- scripts/lib/host-native-launcher.ps1 | 7 +- scripts/lib/platform/platform-adapter.ps1 | 60 ++++++++++++-- .../tests/test-deploy-governance-static.ps1 | 82 +++++++++++++++++++ scripts/tests/test-host-native-launcher.ps1 | 22 +++++ scripts/tests/test-platform-adapter.ps1 | 44 ++++++++++ 6 files changed, 211 insertions(+), 14 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 38347e4fe..8bc87d62b 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -756,7 +756,8 @@ Write-DeployHeader -Title 'Phase 1: Preflight (read-only)' $docker = Test-DockerEnvironment -RepoRoot $RepoRoot $hostNative = Test-HostNativeEnvironment -RepoRoot $RepoRoot $envFiles = Test-EnvFiles -RepoRoot $RepoRoot -$resolvedEnvFile = if ([string]::IsNullOrWhiteSpace($EnvFile)) { +$resolvedEnvFileIsExplicit = -not [string]::IsNullOrWhiteSpace($EnvFile) +$resolvedEnvFile = if (-not $resolvedEnvFileIsExplicit) { # $docker.envFile 解析順序(見 preflight-docker.ps1 Test-DockerEnvironment): # real .env.web-plane.host-kit 存在 → '.env.web-plane.host-kit' # 只有 .example 存在 → '.env.web-plane.host-kit.example'(dev/demo fallback,發 Warning) @@ -1363,9 +1364,10 @@ foreach ($ef in $envFiles) { Write-DeployTag -Tag 'fix' -Message "Copy-Item $examplePath -> $envPath" -LogPath $LogPath | Out-Null Copy-Item -LiteralPath $examplePath -Destination $envPath -Force $fixActions++ - # 若剛 copy 的是 host-kit env,$resolvedEnvFile 要切到真檔(否則後續 volume - # alignment / rm / build 仍指 .example) - if ($ef.file -eq '.env.web-plane.host-kit') { + # 若 preflight 自動選到 host-kit .example,copy 後要切到真檔(否則 + # 後續 volume alignment / rm / build 仍指 .example)。明示 -EnvFile + # 是 operator authority,不能被 canonical bootstrap 靜默覆蓋。 + if ($ef.file -eq '.env.web-plane.host-kit' -and -not $resolvedEnvFileIsExplicit) { $resolvedEnvFile = '.env.web-plane.host-kit' $script:resolvedEnvFile = $resolvedEnvFile $volume = Resolve-DeployVolumeState -Volume (Test-VolumeAlignment -RepoRoot $RepoRoot -EnvFile $resolvedEnvFile) -EdgeRuntimeContract $edgeRuntimeContract diff --git a/scripts/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 886faf98e..f279c002c 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -656,7 +656,12 @@ function Stop-HostNativeProcessTreeAndWait { while ($pending.Count -gt 0) { $current = [int]$pending[0] $pending = @($pending | Select-Object -Skip 1) - foreach ($childId in @(& $LookupFn $current)) { + try { + $currentChildIds = @(& $LookupFn $current) + } catch { + throw ("Process tree child enumeration failed for PID {0}: {1}" -f $current, $_.Exception.Message) + } + foreach ($childId in $currentChildIds) { $childProcessId = [int]$childId if ($visited.ContainsKey($childProcessId)) { continue } $visited[$childProcessId] = $true diff --git a/scripts/lib/platform/platform-adapter.ps1 b/scripts/lib/platform/platform-adapter.ps1 index 7a0016110..0df5b8f06 100644 --- a/scripts/lib/platform/platform-adapter.ps1 +++ b/scripts/lib/platform/platform-adapter.ps1 @@ -30,13 +30,26 @@ function Get-PlatformChildProcessIds { param([Parameter(Mandatory = $true)][int] $ParentProcessId) if ((Get-PlatformName) -eq 'windows') { - return @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$ParentProcessId" -ErrorAction SilentlyContinue | - ForEach-Object { [int]$_.ProcessId }) + try { + return @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$ParentProcessId" -ErrorAction Stop | + ForEach-Object { [int]$_.ProcessId }) + } catch { + throw "platform_child_enumeration_failed: unable to query Windows children of PID ${ParentProcessId}: $($_.Exception.Message)" + } + } + try { + $procDirectories = @(Get-ChildItem -LiteralPath '/proc' -Directory -ErrorAction Stop) + } catch { + throw "platform_child_enumeration_failed: unable to enumerate /proc for children of PID ${ParentProcessId}: $($_.Exception.Message)" } $children = [System.Collections.Generic.List[int]]::new() - foreach ($dir in Get-ChildItem -LiteralPath '/proc' -Directory -ErrorAction SilentlyContinue) { + foreach ($dir in $procDirectories) { if ($dir.Name -notmatch '^\d+$') { continue } - $stat = Get-PlatformProcStatFields -ProcessId ([int]$dir.Name) + try { + $stat = Get-PlatformProcStatFields -ProcessId ([int]$dir.Name) -FailOnReadError + } catch { + throw "platform_child_enumeration_failed: unable to inspect /proc/$($dir.Name) while enumerating children of PID ${ParentProcessId}: $($_.Exception.Message)" + } if ($null -ne $stat -and $stat.ParentProcessId -eq $ParentProcessId) { $children.Add([int]$dir.Name) } @@ -67,17 +80,46 @@ function Test-OrphanRediscoverySupported { function Get-PlatformProcStatFields { # Parses /proc//stat. comm (field 2) may contain spaces/parens, so split # on the LAST ')' before reading positional fields. - param([Parameter(Mandatory = $true)][int] $ProcessId) + param( + [Parameter(Mandatory = $true)][int] $ProcessId, + [switch] $FailOnReadError + ) $statPath = "/proc/$ProcessId/stat" - if (-not (Test-Path -LiteralPath $statPath)) { return $null } - try { $raw = Get-Content -LiteralPath $statPath -Raw -ErrorAction Stop } catch { return $null } + try { + if (-not (Test-Path -LiteralPath $statPath -PathType Leaf -ErrorAction Stop)) { return $null } + } catch { + if ($FailOnReadError) { + throw "platform_proc_stat_read_failed: unable to inspect ${statPath}: $($_.Exception.Message)" + } + return $null + } + try { + $raw = Get-Content -LiteralPath $statPath -Raw -ErrorAction Stop + } catch { + # A process can disappear between listing /proc and reading stat. That is + # a successful observation that this candidate no longer exists. Any + # failure while the entry still exists is unknown and must fail closed + # for child enumeration instead of being converted to an empty set. + $entryStillExists = $false + try { $entryStillExists = Test-Path -LiteralPath $statPath -PathType Leaf -ErrorAction Stop } catch { $entryStillExists = $true } + if ($FailOnReadError -and $entryStillExists) { + throw "platform_proc_stat_read_failed: unable to read ${statPath}: $($_.Exception.Message)" + } + return $null + } $closeIndex = $raw.LastIndexOf(')') - if ($closeIndex -lt 0) { return $null } + if ($closeIndex -lt 0) { + if ($FailOnReadError) { throw "platform_proc_stat_read_failed: malformed ${statPath} (missing process-name terminator)." } + return $null + } $rest = $raw.Substring($closeIndex + 1).Trim() -split '\s+' # $rest[0] = state (field 3), $rest[1] = ppid (field 4), $rest[3] = session # (field 6), $rest[19] = starttime (field 22) - if ($rest.Count -lt 20) { return $null } + if ($rest.Count -lt 20) { + if ($FailOnReadError) { throw "platform_proc_stat_read_failed: malformed ${statPath} (expected at least 20 trailing fields)." } + return $null + } return [pscustomobject]@{ ParentProcessId = [int]$rest[1] SessionId = [int]$rest[3] diff --git a/scripts/tests/test-deploy-governance-static.ps1 b/scripts/tests/test-deploy-governance-static.ps1 index 925cf2f1d..f34656abe 100644 --- a/scripts/tests/test-deploy-governance-static.ps1 +++ b/scripts/tests/test-deploy-governance-static.ps1 @@ -469,6 +469,88 @@ if ($kitManagerStartIndex -le $kitManagerSignatureIndex) { throw 'deploy.ps1 must start the host-native Kit Manager with the post-merge control authority' } +# The Phase 2 copy still has to materialize the canonical env for the default +# deployment path, but it must not replace an operator-selected -EnvFile. Run +# only that loop in a sandbox so this regression is proved without starting any +# runtime or depending on Docker preflight. +$envMergeLoops = @($deployAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Extent.Text -match 'Copy-Item\s+-LiteralPath\s+\$examplePath' +}, $true)) +if ($envMergeLoops.Count -ne 1) { + throw 'deploy.ps1 must expose exactly one Phase 2 env merge loop' +} +function Invoke-EnvMergeFixture { + param( + [Parameter(Mandatory = $true)][string] $LoopText, + [Parameter(Mandatory = $true)][string] $FixtureRoot, + [Parameter(Mandatory = $true)][string] $InitialResolvedEnvFile, + [Parameter(Mandatory = $true)][bool] $ResolvedEnvFileIsExplicit + ) + function Write-DeployTag { param($Tag, $Message, $LogPath) } + function Test-VolumeAlignment { param($RepoRoot, $EnvFile); [pscustomobject]@{ status = 'ALIGNED' } } + function Resolve-DeployVolumeState { param($Volume, $EdgeRuntimeContract); $Volume } + + $RepoRoot = $FixtureRoot + $LogPath = Join-Path $FixtureRoot 'deploy.log' + $edgeRuntimeContract = $null + $envFiles = @([pscustomobject]@{ + file = '.env.web-plane.host-kit' + envExists = $false + exampleExists = $true + missing = @() + }) + $resolvedEnvFile = $InitialResolvedEnvFile + $script:resolvedEnvFile = $InitialResolvedEnvFile + $resolvedEnvFileIsExplicit = $ResolvedEnvFileIsExplicit + $volume = [pscustomobject]@{ status = 'ALIGNED' } + $fixActions = 0 + . ([scriptblock]::Create($LoopText)) + return [pscustomobject]@{ + Resolved = $resolvedEnvFile + ScriptResolved = $script:resolvedEnvFile + Copied = Test-Path -LiteralPath (Join-Path $FixtureRoot '.env.web-plane.host-kit') -PathType Leaf + FixActions = $fixActions + } +} +$envMergeSandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("ai-bim-explicit-env-{0}" -f [guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $envMergeSandbox -Force | Out-Null + [System.IO.File]::WriteAllText( + (Join-Path $envMergeSandbox '.env.web-plane.host-kit.example'), + "KIT_CONTROL_URL=`n" + ) + [System.IO.File]::WriteAllText( + (Join-Path $envMergeSandbox 'custom.env'), + "KIT_CONTROL_URL=http://127.0.0.1:49100/control`n" + ) + + $explicitEnvResult = Invoke-EnvMergeFixture ` + -LoopText $envMergeLoops[0].Extent.Text ` + -FixtureRoot $envMergeSandbox ` + -InitialResolvedEnvFile 'custom.env' ` + -ResolvedEnvFileIsExplicit $true + if (-not $explicitEnvResult.Copied -or $explicitEnvResult.FixActions -ne 1) { + throw 'Phase 2 must still materialize the missing canonical env when an explicit env file is selected' + } + if ($explicitEnvResult.Resolved -cne 'custom.env' -or $explicitEnvResult.ScriptResolved -cne 'custom.env') { + throw 'Phase 2 must preserve an explicitly selected -EnvFile after materializing the canonical fallback' + } + + Remove-Item -LiteralPath (Join-Path $envMergeSandbox '.env.web-plane.host-kit') -Force + $fallbackEnvResult = Invoke-EnvMergeFixture ` + -LoopText $envMergeLoops[0].Extent.Text ` + -FixtureRoot $envMergeSandbox ` + -InitialResolvedEnvFile '.env.web-plane.host-kit.example' ` + -ResolvedEnvFileIsExplicit $false + if ($fallbackEnvResult.Resolved -cne '.env.web-plane.host-kit' -or $fallbackEnvResult.ScriptResolved -cne '.env.web-plane.host-kit') { + throw 'Phase 2 must repoint the automatic .example fallback after materializing the canonical env' + } +} finally { + Remove-Item -LiteralPath $envMergeSandbox -Recurse -Force -ErrorAction SilentlyContinue +} + # Hybrid mode must not start a CONTAINERISED kit-manager-api. `compose up # coordinator viewer` used to pull it in through coordinator's depends_on, and # that service publishes 127.0.0.1:8010 - the same port deploy.ps1 Phase 4c-2 diff --git a/scripts/tests/test-host-native-launcher.ps1 b/scripts/tests/test-host-native-launcher.ps1 index 0dbbb1578..2f303538a 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -1371,6 +1371,28 @@ time.sleep(120) Assert-True ($null -eq $escapeeGrandchildAfterStop) 'a descendant missed by the snapshot is still discovered, stopped and proven gone' Assert-True ($escapeeLookups.Count -gt 2) 'containment re-enumerated past the snapshot that missed it' Write-TestPass 'a descendant appearing after enumeration is caught by the clean-pass fixed point' + + # Case 12 (#513 review P2): platform enumeration failure is ignorance, not + # proof that the tree is empty. Preserve the failing parent PID in the error + # so callers can distinguish this trust-boundary failure from a clean pass. + $lookupFailureFixture = Join-Path $treeSandbox 'lookup-failure-fixture.py' + $lookupFailurePidFile = Join-Path $treeSandbox 'lookup-failure-pids.json' + [System.IO.File]::WriteAllText($lookupFailureFixture, $survivorSource) + $lookupFailureProcess = Start-TreeFixtureProcess -PythonExe $fixturePython -ScriptPath $lookupFailureFixture -PidPath $lookupFailurePidFile + $lookupFailure = '' + try { + Stop-HostNativeProcessTreeAndWait -Process $lookupFailureProcess -TimeoutMs 1000 ` + -ChildPidLookup { param($parentId); throw 'simulated platform child enumeration failure' } + } catch { + $lookupFailure = $_.Exception.Message + } finally { + if (-not $lookupFailureProcess.HasExited) { + $lookupFailureProcess.Kill() + [void]$lookupFailureProcess.WaitForExit(5000) + } + } + Assert-True ($lookupFailure -match "Process tree child enumeration failed for PID $($lookupFailureProcess.Id)") 'process-tree cleanup fails closed with PID context when child enumeration is unavailable' + Write-TestPass 'process-tree cleanup fails closed on child enumeration failure' } finally { Remove-TestSandbox -Path $treeSandbox diff --git a/scripts/tests/test-platform-adapter.ps1 b/scripts/tests/test-platform-adapter.ps1 index 1a1101e13..2cc41f29c 100644 --- a/scripts/tests/test-platform-adapter.ps1 +++ b/scripts/tests/test-platform-adapter.ps1 @@ -53,6 +53,50 @@ while ((Get-Date) -lt $deadline -and $null -ne (Get-PlatformProcessIdentity -Pro Assert-True ($null -eq (Get-PlatformProcessIdentity -ProcessId $child.Id)) 'dead child identity must resolve to null' Assert-True (-not (Test-PlatformProcessIdentityMatch -Reference $childIdentity -Current (Get-PlatformProcessIdentity -ProcessId $child.Id))) 'match against dead process must be false' +# A platform query failure is not an empty child set. Shadow the native query +# for this dynamic scope with a non-terminating error: -ErrorAction Stop in the +# adapter must promote it and fail closed, while SilentlyContinue would erase it. +$childEnumerationFailure = & { + if ($platform -eq 'windows') { + function Get-CimInstance { + [CmdletBinding()] + param([Parameter(Position = 0)] $ClassName, [string] $Filter) + Write-Error 'simulated platform child enumeration failure' + } + } else { + function Get-ChildItem { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Directory) + Write-Error 'simulated platform child enumeration failure' + } + } + try { + @(Get-PlatformChildProcessIds -ParentProcessId $PID) | Out-Null + return '' + } catch { + return $_.Exception.Message + } +} +Assert-True ($childEnumerationFailure -match 'platform_child_enumeration_failed') 'child enumeration errors must be distinguishable from a successful empty result' + +if ($platform -eq 'linux') { + $procStatFailure = & { + function Get-Content { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Raw) + Write-Error 'simulated proc stat read failure' + } + try { + Get-PlatformProcStatFields -ProcessId $PID -FailOnReadError | Out-Null + return '' + } catch { + return $_.Exception.Message + } + } + Assert-True ($procStatFailure -match 'platform_proc_stat_read_failed') 'an unreadable live /proc entry must fail child enumeration closed' + Assert-True ($null -eq (Get-PlatformProcStatFields -ProcessId 2147483647 -FailOnReadError)) 'a process that disappeared before the stat read remains a normal empty result' +} + # --- tcp listener ownership -------------------------------------------------------- $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start()