From 56f4ece8feb34c7b1bef808f19d0eb41ce5020f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:04:00 +0000 Subject: [PATCH 1/3] Initial plan From b095ed3a3c815f0c3dac4100c2d29c81bdb7f6da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:12:48 +0000 Subject: [PATCH 2/3] feat: implement RequestSnapshot export contract for Request.Intent and Request.Context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Limit-IdleSnapshotField helper and per-field 64 KB size limits in ConvertTo-IdlePlanExportObject; oversized identityKeys/intent/context fields are replaced with a deterministic [TRUNCATED - N bytes] marker - Add tests/Core/RequestSnapshot.Tests.ps1: 10 tests covering Intent/Context inclusion, ScriptBlock redaction at export boundary, and size limit behaviour - Update docs/reference/specs/plan-export.md: fix desiredState → intent/context, add input sub-fields table, add Safety rules section (secrets, ScriptBlocks, size)" Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/specs/plan-export.md | 43 ++++- .../ConvertTo-IdlePlanExportObject.ps1 | 38 ++++ tests/Core/RequestSnapshot.Tests.ps1 | 172 ++++++++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 tests/Core/RequestSnapshot.Tests.ps1 diff --git a/docs/reference/specs/plan-export.md b/docs/reference/specs/plan-export.md index d7f11107..be4cde1b 100644 --- a/docs/reference/specs/plan-export.md +++ b/docs/reference/specs/plan-export.md @@ -92,8 +92,13 @@ The request object captures *why* a plan was created, independent of *how* it wi "identityKeys": { "userId": "jdoe" }, - "desiredState": { + "intent": { "department": "IT" + }, + "context": { + "Identity": { + "ObjectId": "abc-123" + } } } } @@ -108,6 +113,14 @@ The request object captures *why* a plan was created, independent of *how* it wi | actor | Originator of the request (system or human), if available | | input | Business intent payload (data-only) | +### input sub-fields (IdLE-native requests) + +| Field | Description | +| ------ | ------------- | +| identityKeys | System-neutral identity lookup keys (e.g. EmployeeId, UPN) | +| intent | Caller-provided action inputs (attributes, entitlements, operator flags) | +| context | Read-only associated context provided by the host or resolvers (e.g. identity snapshots, device hints) | + ### Rules - The `request` object represents **business intent**, not execution details. @@ -119,11 +132,35 @@ The request object captures *why* a plan was created, independent of *how* it wi - no executable expressions - no runtime handles - For **IdLE-native lifecycle requests**, `input` SHOULD contain: - - `identityKeys` – identifiers of the target identity - - `desiredState` – intended target state + - `identityKeys` – system-neutral identity lookup keys + - `intent` – caller-provided action inputs + - `context` – read-only associated context + - The standard `New-IdleRequest` / `New-IdleRequestObject` factory guarantees all three fields + are present (normalised to empty objects when not provided by the caller). - Hosts MAY include additional fields in `input`. - The request payload is exported for **audit, approval, and traceability purposes** and MUST remain stable once the plan is created. +### Safety rules + +The export pipeline enforces the following safety rules before writing `input` to the artifact: + +**Secret prevention (redaction)** +Fields with names that match known secret keys (e.g. `password`, `token`, `secret`, `apiKey`, +`clientSecret`, `accessToken`, `refreshToken`, `privateKey`, `credential`) are replaced with +`[REDACTED]` before the artifact is written. +This applies at all nesting depths. +`PSCredential` and `SecureString` values are redacted regardless of key name. + +**Executable / unsafe type prevention** +`ScriptBlock` objects are replaced with `[REDACTED]` at the export boundary. +Non-serializable objects are converted to their string representation. + +**Size limits** +Each of `identityKeys`, `intent`, and `context` is individually bounded to **64 KB** of +serialized UTF-8 JSON. Fields exceeding this limit are replaced with the deterministic marker +`[TRUNCATED - N bytes]`, where N is the pre-truncation byte count. +This bound prevents unbounded snapshot artifacts while keeping the marker auditable. + ## Plan Object ```json diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index f5b3639b..e216a464 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -50,6 +50,33 @@ function ConvertTo-IdlePlanExportObject { return $null } + # Maximum UTF-8 byte count for a single snapshot field (identityKeys, intent, context). + # Fields serializing beyond this limit are replaced with a deterministic truncation marker + # to prevent unbounded snapshot artifacts. 64 KB is a conservative bound per field. + $snapshotFieldSizeLimit = 65536 + + function Limit-IdleSnapshotField { + # Returns the value unchanged if its serialized UTF-8 size is within $snapshotFieldSizeLimit. + # Returns a '[TRUNCATED - N bytes]' marker string when the limit is exceeded. + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return $null } + + $serialized = $Value | ConvertTo-Json -Depth 20 -Compress + $byteCount = [System.Text.Encoding]::UTF8.GetByteCount($serialized) + + if ($byteCount -gt $snapshotFieldSizeLimit) { + return "[TRUNCATED - $byteCount bytes]" + } + + return $Value + } + # ---- Engine block -------------------------------------------------------- $engineMap = New-OrderedMap $engineMap.name = 'IdLE' @@ -119,6 +146,17 @@ function ConvertTo-IdlePlanExportObject { $null } + # Enforce per-field size limits on the redacted snapshot fields. + # identityKeys, intent and context are each bounded to $snapshotFieldSizeLimit bytes (serialized UTF-8). + # Fields that exceed the limit are replaced with a deterministic truncation marker. + if ($null -ne $redactedRequestInput -and $redactedRequestInput -is [System.Collections.IDictionary]) { + foreach ($fieldName in @('identityKeys', 'intent', 'context')) { + if ($redactedRequestInput.Contains($fieldName)) { + $redactedRequestInput[$fieldName] = Limit-IdleSnapshotField -Value $redactedRequestInput[$fieldName] + } + } + } + $requestMap = New-OrderedMap $requestMap.type = $requestType $requestMap.correlationId = $correlationId diff --git a/tests/Core/RequestSnapshot.Tests.ps1 b/tests/Core/RequestSnapshot.Tests.ps1 new file mode 100644 index 00000000..60f5b2fc --- /dev/null +++ b/tests/Core/RequestSnapshot.Tests.ps1 @@ -0,0 +1,172 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'RequestSnapshot - plan export contract' { + + InModuleScope 'IdLE.Core' { + + # --------------------------------------------------------------------------- + # Helper: build a minimal plan object shaped like the output of New-IdlePlanObject + # --------------------------------------------------------------------------- + BeforeAll { + function New-TestPlan { + param( + [hashtable] $Intent = @{}, + [hashtable] $Context = @{}, + [hashtable] $IdentityKeys = @{ userId = 'jdoe' } + ) + + [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequestSnapshot' + LifecycleEvent = 'Joiner' + CorrelationId = '11111111-1111-1111-1111-111111111111' + Actor = $null + IdentityKeys = $IdentityKeys + Intent = $Intent + Context = $Context + } + Steps = @() + OnFailureSteps = @() + } + } + } + + Context 'Intent and Context are included in request.input' { + It 'exports Request.Intent into request.input.intent' { + $plan = New-TestPlan -Intent @{ department = 'IT'; title = 'Engineer' } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.intent | Should -Not -BeNullOrEmpty + $json.request.input.intent.department | Should -Be 'IT' + $json.request.input.intent.title | Should -Be 'Engineer' + } + + It 'exports Request.Context into request.input.context' { + $plan = New-TestPlan -Context @{ Identity = @{ ObjectId = 'abc-123' } } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.context | Should -Not -BeNullOrEmpty + $json.request.input.context.Identity.ObjectId | Should -Be 'abc-123' + } + + It 'exports an empty context as an empty object (not null)' { + $plan = New-TestPlan -Context @{} + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.context | Should -Not -Be $null + } + + It 'exports IdentityKeys alongside Intent and Context' { + $plan = New-TestPlan ` + -IdentityKeys @{ userId = 'jdoe'; employeeId = '42' } ` + -Intent @{ department = 'IT' } ` + -Context @{ source = 'HR' } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.identityKeys.userId | Should -Be 'jdoe' + $json.request.input.identityKeys.employeeId | Should -Be '42' + $json.request.input.intent.department | Should -Be 'IT' + $json.request.input.context.source | Should -Be 'HR' + } + } + + Context 'ScriptBlock safety' { + It 'redacts a ScriptBlock value inside Intent at the export boundary' { + # ScriptBlocks are rejected at request-creation time via Assert-IdleNoScriptBlock. + # Here we verify the export boundary independently also redacts them, + # guarding against any path that bypasses request-creation validation. + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequestSnapshot' + LifecycleEvent = 'Joiner' + CorrelationId = 'corr-sb-01' + Actor = $null + IdentityKeys = @{ userId = 'test' } + Intent = @{ action = { Write-Output 'bad' } } + Context = @{} + } + Steps = @() + OnFailureSteps = @() + } + + $json = Export-IdlePlanObject -Plan $plan + + $json | Should -Not -Match 'bad' + $json | Should -Match '\[REDACTED\]' + } + + It 'does not emit ScriptBlock text in the exported JSON for Context' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequestSnapshot' + LifecycleEvent = 'Joiner' + CorrelationId = 'corr-sb-02' + Actor = $null + IdentityKeys = @{ userId = 'test' } + Intent = @{} + Context = @{ compute = { 1 + 1 } } + } + Steps = @() + OnFailureSteps = @() + } + + $json = Export-IdlePlanObject -Plan $plan + + $json | Should -Not -Match '1 \+ 1' + $json | Should -Match '\[REDACTED\]' + } + } + + Context 'Size limits' { + It 'truncates an oversized Intent field with a deterministic marker' { + # Build an Intent whose serialized JSON exceeds 64 KB (65536 bytes). + $largeString = 'x' * 70000 + $plan = New-TestPlan -Intent @{ bigField = $largeString } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.intent | Should -BeLike '*TRUNCATED*bytes*' + } + + It 'truncates an oversized Context field with a deterministic marker' { + $largeString = 'x' * 70000 + $plan = New-TestPlan -Context @{ bigField = $largeString } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.context | Should -BeLike '*TRUNCATED*bytes*' + } + + It 'does not truncate Intent that is within the 64 KB limit' { + $plan = New-TestPlan -Intent @{ department = 'IT' } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.intent | Should -Not -BeLike '*TRUNCATED*' + $json.request.input.intent.department | Should -Be 'IT' + } + + It 'truncation marker includes the original byte count' { + $largeString = 'x' * 70000 + $plan = New-TestPlan -Intent @{ bigField = $largeString } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $marker = $json.request.input.intent + $marker | Should -Match '^\[TRUNCATED - \d+ bytes\]$' + } + } + } +} From 11c6caad4f34e33269f6d73cbf0f786f51688f45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:41:39 +0000 Subject: [PATCH 3/3] fix: enforce size limits for PSCustomObject request.input shape The per-field 64 KB truncation was only applied when request.input was an IDictionary. When Copy-IdleRedactedObject returns a PSCustomObject (e.g. caller provided Request.Input as a PSCustomObject / deserialized JSON), the size check was silently skipped and fields could be exported unbounded. Fix: check PSObject.Properties when input is not an IDictionary. Add regression test covering the PSCustomObject path. Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../ConvertTo-IdlePlanExportObject.ps1 | 15 ++++++++--- tests/Core/RequestSnapshot.Tests.ps1 | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index e216a464..dc83bd33 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -149,10 +149,19 @@ function ConvertTo-IdlePlanExportObject { # Enforce per-field size limits on the redacted snapshot fields. # identityKeys, intent and context are each bounded to $snapshotFieldSizeLimit bytes (serialized UTF-8). # Fields that exceed the limit are replaced with a deterministic truncation marker. - if ($null -ne $redactedRequestInput -and $redactedRequestInput -is [System.Collections.IDictionary]) { + # Handle both IDictionary (hashtable / ordered) and PSCustomObject shapes. + if ($null -ne $redactedRequestInput) { foreach ($fieldName in @('identityKeys', 'intent', 'context')) { - if ($redactedRequestInput.Contains($fieldName)) { - $redactedRequestInput[$fieldName] = Limit-IdleSnapshotField -Value $redactedRequestInput[$fieldName] + if ($redactedRequestInput -is [System.Collections.IDictionary]) { + if ($redactedRequestInput.Contains($fieldName)) { + $redactedRequestInput[$fieldName] = Limit-IdleSnapshotField -Value $redactedRequestInput[$fieldName] + } + } + else { + $prop = $redactedRequestInput.PSObject.Properties[$fieldName] + if ($null -ne $prop) { + $prop.Value = Limit-IdleSnapshotField -Value $prop.Value + } } } } diff --git a/tests/Core/RequestSnapshot.Tests.ps1 b/tests/Core/RequestSnapshot.Tests.ps1 index 60f5b2fc..618974ad 100644 --- a/tests/Core/RequestSnapshot.Tests.ps1 +++ b/tests/Core/RequestSnapshot.Tests.ps1 @@ -167,6 +167,32 @@ Describe 'RequestSnapshot - plan export contract' { $marker = $json.request.input.intent $marker | Should -Match '^\[TRUNCATED - \d+ bytes\]$' } + + It 'enforces size limits when request.input is a PSCustomObject (not a hashtable)' { + # Verifies the fix for the path where Copy-IdleRedactedObject returns a PSCustomObject + # (e.g. when the caller provides Request.Input as an already-built PSCustomObject). + $largeString = 'x' * 70000 + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + Type = 'Joiner' + CorrelationId = 'corr-pscobj-01' + Actor = $null + Input = [pscustomobject]@{ + identityKeys = [pscustomobject]@{ userId = 'jdoe' } + intent = [pscustomobject]@{ bigField = $largeString } + context = [pscustomobject]@{} + } + } + Steps = @() + OnFailureSteps = @() + } + + $json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json + + $json.request.input.intent | Should -BeLike '*TRUNCATED*bytes*' + } } } }