*/ private const MUTATION_COMMAND_PREFIXES = [ 'Set', 'New', 'Remove', 'Enable', 'Disable', 'Update', 'Start', 'Stop', 'Invoke', 'Search', 'Export', 'Import', ]; /** * @return list */ public function includedCanonicalTypes(): array { return array_keys($this->contracts()); } /** * @return list */ public function commandNames(): array { return array_values(array_map( static fn (array $contract): string => (string) $contract['command_name'], $this->contracts(), )); } /** * @return list */ public function excludedCanonicalTypes(): array { return [ 'acceptedDomain', 'organizationConfig', 'mailboxPlan', 'outboundConnector', 'sharingPolicy', 'appPermissionPolicy', 'appSetupPolicy', 'meetingPolicy', 'messagingPolicy', 'teamsUpdateManagementPolicy', 'teamsChannelsPolicy', 'externalAccessPolicy', ]; } public function hasContractForCanonicalType(string $canonicalType): bool { return array_key_exists($canonicalType, $this->contracts()); } /** * @return array|null */ public function contractForCanonicalType(string $canonicalType): ?array { return $this->contracts()[$canonicalType] ?? null; } /** * @return array|null */ public function contractForCommandName(string $commandName): ?array { foreach ($this->contracts() as $contract) { if (($contract['command_name'] ?? null) === $commandName) { return $contract; } } return null; } /** * @return array{accepted: bool, reason_code: string|null} */ public function validateCommandName(mixed $commandName): array { if (! is_string($commandName) || trim($commandName) === '') { return $this->rejected('raw_command_rejected'); } $commandName = trim($commandName); if ($this->containsUnsafeCommandText($commandName)) { return $this->rejected('unsafe_command_text_rejected'); } foreach (self::MUTATION_COMMAND_PREFIXES as $prefix) { if (str_starts_with($commandName, $prefix.'-')) { return $this->rejected('mutation_command_rejected'); } } if (! in_array($commandName, $this->commandNames(), true)) { return $this->rejected('command_not_allowlisted'); } return $this->accepted(); } /** * @param array $parameters * @return array{accepted: bool, reason_code: string|null, rejected_parameter?: string} */ public function validateInvocation(mixed $contract, array $parameters = []): array { if (! is_array($contract) || array_is_list($contract)) { return $this->rejected('raw_command_rejected'); } $commandName = $contract['command_name'] ?? null; $commandValidation = $this->validateCommandName($commandName); if (! $commandValidation['accepted']) { return $commandValidation; } $allowlistedContract = $this->contractForCommandName((string) $commandName); if ($allowlistedContract === null || ($contract['contract_key'] ?? null) !== $allowlistedContract['contract_key'] || ($contract['read_only'] ?? null) !== true ) { return $this->rejected('invalid_command_contract'); } $allowedParameters = $contract['allowed_parameters'] ?? []; if (! is_array($allowedParameters) || ! array_is_list($allowedParameters)) { return $this->rejected('invalid_allowed_parameter_policy'); } foreach (array_keys($parameters) as $parameterName) { if (! is_string($parameterName) || ! in_array($parameterName, $allowedParameters, true)) { return [ ...$this->rejected('unknown_parameter_rejected'), 'rejected_parameter' => (string) $parameterName, ]; } } foreach ($parameters as $parameterName => $parameterValue) { if ($this->containsUnsafeParameterValue($parameterValue)) { return [ ...$this->rejected('unsafe_parameter_value_rejected'), 'rejected_parameter' => (string) $parameterName, ]; } } return $this->accepted(); } /** * @return array> */ public function contracts(): array { return [ 'transportRule' => $this->contract( canonicalType: 'transportRule', humanLabel: 'Transport rule', commandName: 'Get-TransportRule', identityFields: ['id', 'sourceId', 'Guid', 'RuleId'], derivedIdentityFields: [], safeFields: ['Guid', 'RuleId', 'DisplayName', 'Name', 'Identity', 'Priority', 'Mode', 'State', 'Enabled'], protectedFields: [ 'SenderDomainIs', 'RecipientDomainIs', 'From', 'SentTo', 'ExceptIfFrom', 'ExceptIfRecipientDomainIs', 'HeaderName', 'SetHeaderName', 'RulePattern', ], sensitiveFields: [ 'SubjectContainsWords', 'SubjectOrBodyContainsWords', 'BodyContainsWords', 'ApplyHtmlDisclaimerText', 'AttachmentContainsWords', 'HeaderContainsWords', 'SetHeaderValue', ], volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'], identityNote: 'Rule GUID-like identifiers are stable candidates; display names, priority, and order are not stable identity.', permissionNotes: ['Mail-flow rule reads require Exchange Online PowerShell application permission and Exchange RBAC validation in a later execution spec.'], ), 'remoteDomain' => $this->contract( canonicalType: 'remoteDomain', humanLabel: 'Remote domain', commandName: 'Get-RemoteDomain', identityFields: ['id', 'sourceId', 'Guid', 'RemoteDomainId', 'Identity'], derivedIdentityFields: ['DomainName', 'domainName'], safeFields: ['Guid', 'RemoteDomainId', 'Identity', 'DomainName', 'Name', 'DisplayName', 'IsDefault', 'AllowedOOFType', 'AutoReplyEnabled'], protectedFields: [ 'DomainName', 'TargetDeliveryDomain', 'TrustedMailOutboundEnabled', 'TNEFEnabled', 'AutoForwardEnabled', ], sensitiveFields: [ 'TargetDeliveryDomain', 'MailTipsAccessLevel', 'MailTipsAccessScope', 'AllowedOOFType', ], volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'], identityNote: 'Default and custom remote domains must be distinguished; domain/name alone is a derived candidate until source stability is proven.', permissionNotes: ['Remote-domain reads require Exchange Online PowerShell application permission and runtime RBAC validation in a later execution spec.'], ), 'inboundConnector' => $this->contract( canonicalType: 'inboundConnector', humanLabel: 'Inbound connector', commandName: 'Get-InboundConnector', identityFields: ['id', 'sourceId', 'Guid', 'ConnectorId', 'Identity'], derivedIdentityFields: [], safeFields: ['Guid', 'ConnectorId', 'Identity', 'Name', 'DisplayName', 'ConnectorType', 'Enabled', 'RequireTls'], protectedFields: [ 'SenderDomains', 'SenderIPAddresses', 'TlsSenderCertificateName', 'AssociatedAcceptedDomains', 'RestrictDomainsToIPAddresses', 'CloudServicesMailEnabled', ], sensitiveFields: [ 'SenderIPAddresses', 'TlsSenderCertificateName', 'ConnectorSource', 'SmartHosts', 'Comment', ], volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'], identityNote: 'Connector identity must use provider/source identifiers where available; display names alone are unsafe.', permissionNotes: ['Connector reads require Exchange Online PowerShell application permission and runtime RBAC validation in a later execution spec.'], ), ]; } /** * @param list $identityFields * @param list $derivedIdentityFields * @param list $safeFields * @param list $protectedFields * @param list $sensitiveFields * @param list $volatileFields * @param list $permissionNotes * @return array */ private function contract( string $canonicalType, string $humanLabel, string $commandName, array $identityFields, array $derivedIdentityFields, array $safeFields, array $protectedFields, array $sensitiveFields, array $volatileFields, string $identityNote, array $permissionNotes, ): array { $contractKey = 'exchange_powershell.'.$canonicalType; return [ 'contract_key' => $contractKey, 'canonical_type' => $canonicalType, 'human_label' => $humanLabel, 'workload' => 'exchange', 'source_surface' => self::SOURCE_SURFACE, 'adapter_pattern' => self::ADAPTER_PATTERN, 'provider_owned_source_detail' => true, 'command_name' => $commandName, 'command_contract_version' => self::COMMAND_CONTRACT_VERSION, 'read_only' => true, 'allowed_parameters' => [], 'collection_shape' => 'collection', 'fake_runner_testable' => true, 'provider_calls_allowed' => false, 'execution_enabled' => false, 'capture_eligibility_state' => 'pending_capture', 'provider_adapter_state' => 'adapter_contract_available', 'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE, 'permission_model' => $this->permissionModel($canonicalType, $permissionNotes), 'response_shape' => $this->responseShape( identityFields: $identityFields, derivedIdentityFields: $derivedIdentityFields, safeFields: $safeFields, protectedFields: $protectedFields, sensitiveFields: $sensitiveFields, volatileFields: $volatileFields, ), 'identity_handoff' => $this->identityHandoff($identityFields, $derivedIdentityFields, $identityNote), 'redaction_rules' => $this->redactionRules($sensitiveFields, $protectedFields), 'normalization_handoff' => $this->normalizationHandoff($canonicalType, $contractKey, $identityFields, $derivedIdentityFields, $volatileFields, $sensitiveFields), 'claim_boundary' => $this->claimBoundary(), 'restore_tier' => 'not_restorable', 'future_execution_scope' => [ 'requires_workspace_id' => true, 'requires_managed_environment_id' => true, 'requires_provider_connection_id' => true, 'provider_native_scope_identifiers_are_metadata_only' => true, ], ]; } /** * @param list $permissionNotes * @return array */ private function permissionModel(string $canonicalType, array $permissionNotes): array { return [ 'status' => 'runtime_validation_pending', 'documentation_notes' => $permissionNotes, 'known_permission_names' => ['Exchange.ManageAsApp'], 'least_privilege_runtime_validated' => false, 'admin_consent_required' => true, 'exchange_rbac_runtime_validated' => false, 'permission_failure_modes' => [ 'permission_denied', 'command_unavailable', 'adapter_unavailable', ], 'permission_failure_mode' => 'block_without_provider_call', 'redacted_permission_context' => true, 'resource_type' => $canonicalType, ]; } /** * @param list $identityFields * @param list $derivedIdentityFields * @param list $safeFields * @param list $protectedFields * @param list $sensitiveFields * @param list $volatileFields * @return array */ private function responseShape( array $identityFields, array $derivedIdentityFields, array $safeFields, array $protectedFields, array $sensitiveFields, array $volatileFields, ): array { return [ 'raw_payload_shape' => 'exchange_powershell_structured_collection', 'collection_semantics' => 'collection', 'collection_item_path' => 'items', 'required_identity_candidate_fields' => $identityFields, 'derived_identity_candidate_fields' => $derivedIdentityFields, 'safe_fields' => $safeFields, 'protected_configuration_fields' => $protectedFields, 'sensitive_fields' => $sensitiveFields, 'volatile_fields' => $volatileFields, 'unsupported_fields' => [], 'empty_collection_meaning' => 'valid_empty_collection', 'permission_denied_response_meaning' => 'permission_denied', 'command_unavailable_response_meaning' => 'command_unavailable', 'adapter_unavailable_response_meaning' => 'adapter_unavailable', 'malformed_response_meaning' => 'malformed_response', 'unexpected_object_shape_meaning' => 'unexpected_object_shape', 'response_shape_safety' => 'fail_closed_until_empty_denied_unavailable_malformed_and_unexpected_shapes_are_distinct', ]; } /** * @param list $identityFields * @param list $derivedIdentityFields * @return array */ private function identityHandoff(array $identityFields, array $derivedIdentityFields, string $identityNote): array { return [ 'preferred_identity_fields' => $identityFields, 'derived_identity_fields' => $derivedIdentityFields, 'fallback_identity_fields' => [], 'identity_stability_class' => 'stable_candidate', 'derived_identity_stability_class' => $derivedIdentityFields === [] ? null : 'derived_candidate', 'singleton_or_collection_identity_rule' => 'collection_item_identity_required', 'display_name_is_stable_identity' => false, 'known_identity_risks' => [ 'display_name_not_stable', 'identity_field_can_be_display_like', 'order_and_payload_hash_not_stable_identity', ], 'note' => $identityNote, ]; } /** * @param list $sensitiveFields * @param list $protectedFields * @return array */ private function redactionRules(array $sensitiveFields, array $protectedFields): array { return [ 'raw_payload_default_visible' => false, 'permission_context_default_visible' => false, 'redacted_permission_context' => true, 'protected_configuration_fields' => $protectedFields, 'sensitive_fields' => [ ...$sensitiveFields, 'tokens', 'secrets', 'authorization_headers', 'cookies', 'certificate_private_material', 'passwords', 'raw_transcripts', 'raw_shell_stdout', 'raw_shell_stderr', 'mail_body_content', 'mailbox_content', 'file_content', 'teams_transcript_content', ], 'forbidden_default_output' => [ 'raw_provider_payload', 'provider_response', 'authorization_header', 'cookies', 'tokens', 'secrets', 'credentials', 'certificate_private_material', 'passwords', 'raw_transcripts', 'raw_shell_stdout', 'raw_shell_stderr', 'mail_body_content', 'mailbox_content', 'file_content', 'teams_transcript_content', ], ]; } /** * @param list $identityFields * @param list $derivedIdentityFields * @param list $volatileFields * @param list $sensitiveFields * @return array */ private function normalizationHandoff( string $canonicalType, string $contractKey, array $identityFields, array $derivedIdentityFields, array $volatileFields, array $sensitiveFields, ): array { return [ 'resource_type' => $canonicalType, 'source_surface' => self::SOURCE_SURFACE, 'source_contract_key' => $contractKey, 'source_version' => self::COMMAND_CONTRACT_VERSION, 'raw_payload_shape' => 'exchange_powershell_structured_collection', 'expected_normalized_shape' => 'future_exchange_powershell_normalized_payload', 'identity_fields' => [ ...$identityFields, ...$derivedIdentityFields, ], 'volatile_fields' => $volatileFields, 'sensitive_fields' => $sensitiveFields, 'collection_item_path' => 'items', ]; } /** * @return array */ private function claimBoundary(): array { return [ 'internal_operator_safe_statement' => self::ALLOWED_INTERNAL_CLAIM, 'customer_claims_allowed' => false, 'evidence_promotion_allowed' => false, 'compare_render_promotion_allowed' => false, 'certification_allowed' => false, 'restore_allowed' => false, 'not_customer_claimable' => true, ]; } /** * @return array{accepted: true, reason_code: null} */ private function accepted(): array { return [ 'accepted' => true, 'reason_code' => null, ]; } /** * @return array{accepted: false, reason_code: string} */ private function rejected(string $reasonCode): array { return [ 'accepted' => false, 'reason_code' => $reasonCode, ]; } private function containsUnsafeCommandText(string $value): bool { $trimmed = trim($value); $lower = strtolower($trimmed); foreach ([';', '|', '>', '<', '`', "\n", "\r", '$(', '{', '}'] as $fragment) { if (str_contains($trimmed, $fragment)) { return true; } } foreach (['install-module', 'invoke-expression', 'start-process', 'out-file', 'set-content', 'add-content'] as $fragment) { if (str_contains($lower, $fragment)) { return true; } } return false; } private function containsUnsafeParameterValue(mixed $value): bool { if (is_string($value)) { return $this->containsUnsafeCommandText($value); } if (! is_array($value)) { return false; } foreach ($value as $nestedValue) { if ($this->containsUnsafeParameterValue($nestedValue)) { return true; } } return false; } }