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. 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/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..17d4092a2 --- /dev/null +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/README.md @@ -0,0 +1,57 @@ +> 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` +- 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 + +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`, 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`. + +## 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`. + +## 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. +- 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..8038c81d3 --- /dev/null +++ b/docs/evidence/mechanism-hardening-2/self-referential-bootstrap/verification.txt @@ -0,0 +1,81 @@ +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) +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 (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 (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, 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 +--- 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 +--- 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 diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 086cbc50c..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) @@ -888,12 +889,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 +970,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') @@ -1106,6 +1104,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) { @@ -1347,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 @@ -1387,6 +1405,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/lib/host-native-launcher.ps1 b/scripts/lib/host-native-launcher.ps1 index 91ccf5967..f279c002c 100644 --- a/scripts/lib/host-native-launcher.ps1 +++ b/scripts/lib/host-native-launcher.ps1 @@ -523,23 +523,313 @@ function Start-HostNativeGovernance { } function Stop-HostNativeProcessTreeAndWait { + # 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: + # + # 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 + # 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". + # + # 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. + # 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 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 + # 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 + # kill behind an overload probe with the enumerated-PID fallback used by + # 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, 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, - [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) + }, + # 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])) + }, + # 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 + } ) - if ($Process.HasExited) { return } - try { - $Process.Kill($true) + # 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) + 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 + $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 + } } - catch { - if (-not $Process.HasExited) { - throw "Process tree termination failed for PID $($Process.Id): $($_.Exception.Message)" + + 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 + + # 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 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. 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 = @{} + $descendantIds = @(Update-DescendantSnapshot ` + -RootProcessIds @($parentProcessId) ` + -KnownProcessIds @() ` + -Identities $descendantIdentities ` + -LookupFn $ChildPidLookup ` + -ProbeFn $IdentityProbeFn) + + $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() + } + } + 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)" + } + } + $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." + } + } + + # 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 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) { + # 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 ` + -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) + }) + # 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 $Process.WaitForExit($TimeoutMs) -or -not $Process.HasExited) { - throw "Process tree for PID $($Process.Id) did not terminate within $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." } } diff --git a/scripts/lib/platform/platform-adapter.ps1 b/scripts/lib/platform/platform-adapter.ps1 index 8dfac3dd9..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) } @@ -44,20 +57,69 @@ 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. - 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/self-referential-bootstrap-ledger.json b/scripts/self-referential-bootstrap-ledger.json index 07a33933c..5ec5e5ffd 100644 --- a/scripts/self-referential-bootstrap-ledger.json +++ b/scripts/self-referential-bootstrap-ledger.json @@ -146,6 +146,48 @@ "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": [ + ".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" + ], + "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-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-deploy-governance-static.ps1 b/scripts/tests/test-deploy-governance-static.ps1 index d19209f69..f34656abe 100644 --- a/scripts/tests/test-deploy-governance-static.ps1 +++ b/scripts/tests/test-deploy-governance-static.ps1 @@ -441,6 +441,116 @@ 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' +} + +# 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 dac8fb969..2f303538a 100644 --- a/scripts/tests/test-host-native-launcher.ps1 +++ b/scripts/tests/test-host-native-launcher.ps1 @@ -753,4 +753,649 @@ 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' + + # 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' + # 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 + # containment claim the helper is supposed to prove. + $orphanChildIdentityAfterStop = 'never-sampled' + $orphanFailure = '' + try { + 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 + } + if ($orphanRediscoverable) { + 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 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' + + # 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 '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. + $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 ` + -OrphanRediscoveryProbeFn { $true } ` + -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' + + # 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' + + # 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' + + # 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 +} + Write-Host "`n=== test-host-native-launcher.ps1: ALL PASSED ===" -ForegroundColor Green 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() 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/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", 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 += @{