guard( ExchangePowerShellProcessResult::completed(0, '[{"Guid":"rule-1","client_secret":"unsafe"}]', durationMs: 10), $contract, spec435RuntimePolicy(), ); $empty = (new ExchangePowerShellOutputGuard)->guard( ExchangePowerShellProcessResult::completed(0, '', durationMs: 11), $contract, spec435RuntimePolicy(), ); $collectionEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult( resourceType: 'transportRule', contract: $contract, runnerMode: 'fake', result: $collection, ); $emptyEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult( resourceType: 'transportRule', contract: $contract, runnerMode: 'fake', result: $empty, ); $collectionItemsJson = json_encode($collectionEnvelope->items, JSON_THROW_ON_ERROR); expect($collectionEnvelope->accepted())->toBeTrue() ->and($collectionEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_COLLECTION) ->and($collectionEnvelope->itemCount)->toBe(1) ->and($collectionEnvelope->items[0]['Guid'] ?? null)->toBe('rule-1') ->and($collectionItemsJson) ->not->toContain('client_secret') ->not->toContain('unsafe') ->and($collectionEnvelope->safeSummary()) ->not->toHaveKey('stdout') ->not->toHaveKey('stderr') ->and(json_encode($collectionEnvelope->safeSummary(), JSON_THROW_ON_ERROR)) ->not->toContain('client_secret') ->and($emptyEnvelope->accepted())->toBeTrue() ->and($emptyEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_EMPTY_COLLECTION) ->and($emptyEnvelope->emptyCollection)->toBeTrue() ->and($emptyEnvelope->itemCount)->toBe(0); }); it('Spec435 strips forbidden secret and raw-output key families from public envelope items recursively', function (): void { $contract = spec435VerifiedContract('transportRule'); $collection = (new ExchangePowerShellOutputGuard)->guard( ExchangePowerShellProcessResult::completed(0, json_encode([[ 'Guid' => 'rule-1', 'Actions' => [ 'SetHeaderValue' => 'business-value-redacted-later', 'authorization_header' => 'Bearer unsafe', ], 'Nested' => [ 'stdout' => 'unsafe-direct-stdout', 'stderr' => 'unsafe-direct-stderr', 'tokens' => ['unsafe-token'], 'Cookies' => ['unsafe-cookie'], 'CredentialMaterial' => 'unsafe-credential', 'PowerShellTranscript' => 'unsafe-transcript', 'RawShellStdout' => 'unsafe-stdout', ], ]], JSON_THROW_ON_ERROR), durationMs: 10), $contract, spec435RuntimePolicy(), ); $envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult( resourceType: 'transportRule', contract: $contract, runnerMode: 'fake', result: $collection, ); $itemsJson = json_encode($envelope->items, JSON_THROW_ON_ERROR); expect($envelope->accepted())->toBeTrue() ->and(data_get($envelope->items, '0.Guid'))->toBe('rule-1') ->and(data_get($envelope->items, '0.Actions.SetHeaderValue'))->toBe('business-value-redacted-later') ->and($itemsJson) ->not->toContain('authorization_header') ->not->toContain('unsafe-token') ->not->toContain('unsafe-cookie') ->not->toContain('unsafe-credential') ->not->toContain('unsafe-transcript') ->not->toContain('unsafe-stdout') ->not->toContain('unsafe-direct-stdout') ->not->toContain('unsafe-direct-stderr'); }); it('Spec435 maps unsafe Exchange process output shapes to explicit readiness blockers', function ( ExchangePowerShellProcessResult $processResult, string $expectedShapeState, string $expectedBlocker, ): void { $contract = spec435VerifiedContract('transportRule'); $result = (new ExchangePowerShellOutputGuard)->guard( $processResult, $contract, spec435RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1), ); $envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult( resourceType: 'transportRule', contract: $contract, runnerMode: 'fake', result: $result, ); expect($envelope->accepted())->toBeFalse() ->and($envelope->shapeState)->toBe($expectedShapeState) ->and($envelope->readinessBlocker)->toBe($expectedBlocker) ->and($envelope->normalizerReadinessState)->toBe(ExchangePowerShellStructuredOutputEnvelope::READINESS_BLOCKED) ->and(json_encode($envelope->safeSummary(), JSON_THROW_ON_ERROR)) ->not->toContain('secret-output'); })->with([ 'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'malformed_json_collection', 'blocked_malformed_output'], 'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'malformed_json_collection', 'blocked_malformed_output'], 'warning-prefixed output' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'warning_prefixed', 'blocked_warning_prefixed_output'], 'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'non_utf8_or_binary', 'blocked_binary_output'], 'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'stdout_oversized', 'blocked_oversized_output'], 'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'stderr_oversized', 'blocked_oversized_output'], 'non-zero exit' => [ExchangePowerShellProcessResult::completed(1, '[{"Guid":"rule-1"}]'), 'nonzero_exit', 'blocked_nonzero_exit'], 'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'timeout', 'blocked_timeout'], ]); function spec435RuntimePolicy( int $stdoutMaxBytes = 524288, int $stderrMaxBytes = 65536, int $itemLimit = 1000, ): ExchangePowerShellRuntimePolicy { return new ExchangePowerShellRuntimePolicy( invocationEnabled: true, productionRunnerEnabled: true, allowedEnvironments: [app()->environment()], currentEnvironment: app()->environment(), powershellBinary: 'pwsh', moduleCheckEnabled: true, checkTimeoutSeconds: 5, invocationTimeoutSeconds: 60, stdoutMaxBytes: $stdoutMaxBytes, stderrMaxBytes: $stderrMaxBytes, itemLimit: $itemLimit, lockTtlSeconds: 300, tempFilesEnabled: false, tempDirectory: null, ); } function spec435VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract { $contracts = new ExchangePowerShellCommandContracts; $contract = $contracts->contractForCanonicalType($canonicalType); if (! is_array($contract)) { throw new RuntimeException('Missing Spec435 command contract.'); } return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []); }