From f9f5f82a467a0fcc0b9009e7c78467f45bdbbdbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:56:48 +0000 Subject: [PATCH 1/2] Initial plan From a3fab9238a33724097ed63c65a382669206a7f98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:09:01 +0000 Subject: [PATCH 2/2] Add execution-time path validation for precondition operators Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Assert-IdleConditionPathsResolvable.ps1 | 38 +++++++++++++------ .../Public/Invoke-IdlePlanObject.ps1 | 17 +++++++-- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 19 ++++++++++ .../missing-context-at-invoke.psd1 | 15 ++++++++ 4 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 0249a54b..d8f1f801 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -24,7 +24,13 @@ function Assert-IdleConditionPathsResolvable { [Parameter()] [AllowNull()] - [object] $WarningSink + [object] $WarningSink, + + # When set, skips validation of paths used by the Exists operator. + # Exists semantics intentionally allow missing paths (returns $false if absent), + # so strict execution-time path validation should exclude those paths. + [Parameter()] + [switch] $ExcludeExistsOperatorPaths ) function Add-IdlePathIfPresent { @@ -61,13 +67,16 @@ function Assert-IdleConditionPathsResolvable { [Parameter(Mandatory)] [AllowEmptyCollection()] - [System.Collections.Generic.List[string]] $PathList + [System.Collections.Generic.List[string]] $PathList, + + [Parameter()] + [switch] $ExcludeExistsPaths ) if ($Node.Contains('All')) { foreach ($child in @($Node.All)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -76,7 +85,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('Any')) { foreach ($child in @($Node.Any)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -85,7 +94,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('None')) { foreach ($child in @($Node.None)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -102,12 +111,17 @@ function Assert-IdleConditionPathsResolvable { } if ($Node.Contains('Exists')) { - $existsVal = $Node.Exists - if ($existsVal -is [string]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal - } - elseif ($existsVal -is [System.Collections.IDictionary]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + # Exists operator semantics: checking for the presence of a path is intentional. + # When -ExcludeExistsPaths is set (e.g. strict execution-time validation), skip these + # so that Exists can still return $false without causing a path-not-found error. + if (-not $ExcludeExistsPaths) { + $existsVal = $Node.Exists + if ($existsVal -is [string]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal + } + elseif ($existsVal -is [System.Collections.IDictionary]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + } } return } @@ -119,7 +133,7 @@ function Assert-IdleConditionPathsResolvable { } $paths = [System.Collections.Generic.List[string]]::new() - Get-IdleConditionPaths -Node $Condition -PathList $paths + Get-IdleConditionPaths -Node $Condition -PathList $paths -ExcludeExistsPaths:$ExcludeExistsOperatorPaths $uniquePaths = @($paths | Select-Object -Unique) if ($uniquePaths.Count -eq 0) { diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f3484670..9cf265cb 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -330,8 +330,13 @@ function Invoke-IdlePlanObject { # Fail closed: a malformed or unexpected node type is treated as a failed precondition. $preconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { - $preconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + # Exists operator paths are excluded because Exists semantics intentionally allow missing paths. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { + $preconditionPassed = $false + } } if (-not $preconditionPassed) { @@ -626,8 +631,12 @@ function Invoke-IdlePlanObject { if ($ofPrecondition -isnot [System.Collections.IDictionary]) { $ofPreconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { - $ofPreconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext -StepName $ofName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { + $ofPreconditionPassed = $false + } } if (-not $ofPreconditionPassed) { diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 8b8c19cb..5bee17cc 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -374,5 +374,24 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 } } + + Context 'Unresolvable precondition path at execution time' { + It 'throws when a non-Exists precondition path is missing from the request context at invoke time' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'missing-context-at-invoke.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.MissingContextAtInvoke' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.MissingContextAtInvoke') + } + + # Planning succeeds with a soft warning for the missing Request.Context path. + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan | Should -Not -BeNullOrEmpty + @($plan.Warnings).Count | Should -BeGreaterThan 0 + + # Execution must throw because the path is still missing at runtime. + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*unresolved condition path*' + } + } } diff --git a/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 new file mode 100644 index 00000000..8737d0a3 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 @@ -0,0 +1,15 @@ +@{ + Name = 'Missing Context Path At Invoke' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.MissingContextAtInvoke' + Precondition = @{ + All = @( + @{ In = @{ Path = 'Request.Context.NA'; Values = @('EU', 'DE') } } + ) + } + } + ) +}