check($policy); expect($result->allowed)->toBeFalse() ->and($result->failureCode)->toBe($expectedFailureCode); })->with([ 'feature disabled' => fn (): array => [ spec432RuntimePolicy(invocationEnabled: false), new FakeExchangePowerShellProcessExecutor, 'runtime_blocked_feature_disabled', ], 'runner disabled' => fn (): array => [ spec432RuntimePolicy(productionRunnerEnabled: false), new FakeExchangePowerShellProcessExecutor, 'runtime_blocked_runner_disabled', ], 'environment not allowed' => fn (): array => [ spec432RuntimePolicy(allowedEnvironments: ['production']), new FakeExchangePowerShellProcessExecutor, 'runtime_blocked_environment_not_allowed', ], 'timeout policy missing' => fn (): array => [ spec432RuntimePolicy(checkTimeoutSeconds: 0), new FakeExchangePowerShellProcessExecutor, 'runtime_blocked_timeout_policy_missing', ], 'temp policy unsafe' => fn (): array => [ spec432RuntimePolicy(tempFilesEnabled: true, tempDirectory: '/tmp/tenantpilot-unsafe'), new FakeExchangePowerShellProcessExecutor, 'runtime_blocked_temp_policy_unsafe', ], 'process executor missing' => fn (): array => [ spec432RuntimePolicy(), new FakeExchangePowerShellProcessExecutor(available: false), 'runtime_blocked_process_executor_missing', ], 'powershell missing' => fn (): array => [ spec432RuntimePolicy(), new FakeExchangePowerShellProcessExecutor(results: [ ExchangePowerShellProcessResult::completed(1, '', 'not found'), ]), 'runtime_blocked_powershell_missing', ], 'module missing' => fn (): array => [ spec432RuntimePolicy(), new FakeExchangePowerShellProcessExecutor(results: [ ExchangePowerShellProcessResult::completed(0, "7.4.0\n"), ExchangePowerShellProcessResult::completed(0, ''), ]), 'runtime_blocked_exchange_module_missing', ], 'module unknown' => fn (): array => [ spec432RuntimePolicy(), new FakeExchangePowerShellProcessExecutor(results: [ ExchangePowerShellProcessResult::completed(0, "7.4.0\n"), ExchangePowerShellProcessResult::completed(1, '', 'module error'), ]), 'runtime_blocked_exchange_module_unknown', ], ]); it('Spec432 runtime readiness uses fixed non-invasive argument vectors', function (): void { $executor = new FakeExchangePowerShellProcessExecutor(results: [ ExchangePowerShellProcessResult::completed(0, "7.4.0\n"), ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"), ]); $result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy()); $arguments = json_encode($executor->calls, JSON_THROW_ON_ERROR); expect($result->allowed)->toBeTrue() ->and($executor->calls)->toHaveCount(2) ->and($arguments) ->toContain('-NoLogo') ->toContain('-NoProfile') ->toContain('-NonInteractive') ->toContain('$PSVersionTable.PSVersion.ToString()') ->toContain('Get-Module -ListAvailable -Name ExchangeOnlineManagement') ->not->toContain('Select-Object') ->not->toContain('| Select-Object') ->not->toContain('Connect-ExchangeOnline') ->not->toContain('Install-Module') ->not->toContain('Invoke-Expression') ->not->toContain('Start-Process') ->not->toContain('Get-ChildItem Env:') ->not->toContain('Read-Host'); }); it('Spec432 runtime readiness catches unexpected executor failures safely', function (): void { $executor = new class implements ExchangePowerShellProcessExecutor { public function available(): bool { return true; } public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult { throw new RuntimeException('executor boom with client_secret=unsafe'); } }; $result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy()); expect($result->allowed)->toBeFalse() ->and($result->failureCode)->toBe('runtime_failed_unexpected') ->and(json_encode($result->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret'); }); it('Spec432 command builder creates only fixed argument-vector commands from verified contracts', function (string $canonicalType, string $commandName): void { $contracts = new ExchangePowerShellCommandContracts; $verifiedContract = spec432VerifiedContract($canonicalType); $result = (new ExchangePowerShellProcessCommandBuilder($contracts))->build($verifiedContract, spec432RuntimePolicy()); expect($result->successful)->toBeTrue() ->and($result->command)->toBeInstanceOf(ExchangePowerShellProcessCommand::class) ->and($result->command?->arguments)->toBeArray() ->and($result->command?->arguments[0])->toBe('pwsh') ->and($result->command?->arguments)->toContain('-NoLogo') ->and($result->command?->arguments)->toContain('-NoProfile') ->and($result->command?->arguments)->toContain('-NonInteractive') ->and($result->command?->arguments[5])->toBe($commandName) ->and($result->command?->arguments[5])->not->toContain('Set-') ->and($result->command?->arguments[5])->not->toContain('New-') ->and($result->command?->arguments[5])->not->toContain('Remove-') ->and($result->command?->arguments[5])->not->toContain('|') ->and($result->command?->arguments[5])->not->toContain(';') ->and($result->command?->arguments[5])->not->toContain('$') ->and($result->command?->arguments[5])->not->toContain('>') ->and($result->command?->arguments[5])->not->toContain('ConvertTo-Json') ->and($result->context['command_builder_state'])->toBe('fixed_allowlist_argument_vector'); })->with([ 'transport rule' => ['transportRule', 'Get-TransportRule'], 'remote domain' => ['remoteDomain', 'Get-RemoteDomain'], 'inbound connector' => ['inboundConnector', 'Get-InboundConnector'], ]); it('Spec432 command contracts reject mutation commands shell fragments and unknown parameters before command building', function (mixed $commandName, string $expectedReason): void { $contracts = new ExchangePowerShellCommandContracts; expect($contracts->validateCommandName($commandName)['reason_code'])->toBe($expectedReason); })->with([ 'set command' => ['Set-TransportRule', 'mutation_command_rejected'], 'new command' => ['New-TransportRule', 'mutation_command_rejected'], 'remove command' => ['Remove-TransportRule', 'mutation_command_rejected'], 'enable command' => ['Enable-TransportRule', 'mutation_command_rejected'], 'disable command' => ['Disable-TransportRule', 'mutation_command_rejected'], 'update command' => ['Update-TransportRule', 'mutation_command_rejected'], 'start command' => ['Start-TransportRule', 'mutation_command_rejected'], 'stop command' => ['Stop-TransportRule', 'mutation_command_rejected'], 'invoke command' => ['Invoke-Expression', 'unsafe_command_text_rejected'], 'search command' => ['Search-TransportRule', 'mutation_command_rejected'], 'export command' => ['Export-TransportRule', 'mutation_command_rejected'], 'import command' => ['Import-TransportRule', 'mutation_command_rejected'], 'semicolon chain' => ['Get-TransportRule; Get-RemoteDomain', 'unsafe_command_text_rejected'], 'pipe chain' => ['Get-TransportRule | Out-File x', 'unsafe_command_text_rejected'], 'redirection' => ['Get-TransportRule > x', 'unsafe_command_text_rejected'], 'script fragment' => ['Get-TransportRule $(Get-Process)', 'unsafe_command_text_rejected'], 'alias unknown' => ['gtr', 'command_not_allowlisted'], ]); it('Spec432 verified command contract rejects unknown parameters without exposing parameter values', function (): void { $contracts = new ExchangePowerShellCommandContracts; $contract = $contracts->contractForCanonicalType('transportRule'); expect($contract)->toBeArray(); $validation = $contracts->validateInvocation($contract, ['client_secret_super_token' => 'unsafe']); expect($validation['accepted'])->toBeFalse() ->and($validation['reason_code'])->toBe('unknown_parameter_rejected') ->and(json_encode($validation, JSON_THROW_ON_ERROR)) ->not->toContain('unsafe'); }); it('Spec432 output guard accepts structured JSON collections and summarizes without raw payload persistence', function (): void { $result = (new ExchangePowerShellOutputGuard)->guard( ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","secret":"raw-output-secret"}]', durationMs: 12), spec432VerifiedContract('transportRule'), spec432RuntimePolicy(), ); expect($result->successful)->toBeTrue() ->and($result->collection)->toHaveCount(1) ->and($result->context['output_state'])->toBe('structured_collection') ->and($result->context['item_count'])->toBe(1); }); it('Spec432 output guard fails safely for unsafe process outputs', function ( ExchangePowerShellProcessResult $processResult, string $expectedFailureCode, ): void { $result = (new ExchangePowerShellOutputGuard)->guard( $processResult, spec432VerifiedContract('transportRule'), spec432RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1), ); expect($result->successful)->toBeFalse() ->and($result->failureCode)->toBe($expectedFailureCode) ->and($result->summaryCounts)->toMatchArray([ 'total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0, ]) ->and(json_encode($result->context, JSON_THROW_ON_ERROR)) ->not->toContain('secret-output'); })->with([ 'warning prefix' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'execution_failed_output_shape_unsafe'], 'nonzero with stdout' => [ExchangePowerShellProcessResult::completed(1, '[{"id":"rule-1"}]'), 'execution_failed_nonzero_exit'], 'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'execution_failed_timeout'], 'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'execution_failed_stdout_oversized'], 'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'execution_failed_stderr_oversized'], 'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'execution_failed_output_encoding'], 'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'execution_failed_shape_unsafe'], 'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'execution_failed_shape_unsafe'], 'too many items' => [ExchangePowerShellProcessResult::completed(0, '[{"id":"one"},{"id":"two"}]'), 'execution_failed_item_limit_exceeded'], 'executor exception' => [ExchangePowerShellProcessResult::exception('RuntimeException'), 'execution_failed_unexpected_exception'], ]); function spec432RuntimePolicy( bool $invocationEnabled = true, bool $productionRunnerEnabled = true, array $allowedEnvironments = [], int $checkTimeoutSeconds = 5, int $invocationTimeoutSeconds = 60, int $stdoutMaxBytes = 524288, int $stderrMaxBytes = 65536, int $itemLimit = 1000, int $lockTtlSeconds = 300, bool $tempFilesEnabled = false, ?string $tempDirectory = null, ): ExchangePowerShellRuntimePolicy { return new ExchangePowerShellRuntimePolicy( invocationEnabled: $invocationEnabled, productionRunnerEnabled: $productionRunnerEnabled, allowedEnvironments: $allowedEnvironments !== [] ? $allowedEnvironments : [app()->environment()], currentEnvironment: app()->environment(), powershellBinary: 'pwsh', moduleCheckEnabled: true, checkTimeoutSeconds: $checkTimeoutSeconds, invocationTimeoutSeconds: $invocationTimeoutSeconds, stdoutMaxBytes: $stdoutMaxBytes, stderrMaxBytes: $stderrMaxBytes, itemLimit: $itemLimit, lockTtlSeconds: $lockTtlSeconds, tempFilesEnabled: $tempFilesEnabled, tempDirectory: $tempDirectory, ); } function spec432VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract { $contracts = new ExchangePowerShellCommandContracts; $contract = $contracts->contractForCanonicalType($canonicalType); if (! is_array($contract)) { throw new RuntimeException('Missing Spec432 command contract.'); } return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []); }