diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index adbe562d6..99f289247 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -1,8 +1,5 @@ name: Azure Template Validation on: - push: - branches: - - main workflow_dispatch: permissions: @@ -37,6 +34,8 @@ jobs: AZURE_ENV_MODEL_CAPACITY: 1 AZURE_ENV_MODEL_4_1_CAPACITY: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AZURE_DEV_COLLECT_TELEMETRY: ${{ vars.AZURE_DEV_COLLECT_TELEMETRY }} + # Step 3: Print the result of the validation - name: print result run: cat ${{ steps.validation.outputs.resultFile }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c86e385b5..2ccc3a90e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Validate Deployment v3 on: workflow_run: - workflows: ["Build Docker and Optional Push"] + workflows: ["Build Docker and Optional Push v3"] types: - completed branches: diff --git a/infra/main.bicep b/infra/main.bicep index 08a3a109a..0c2451531 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -381,338 +381,20 @@ module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-id enableTelemetry: enableTelemetry } } - -// ========== Network Security Groups ========== // -// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network -// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking -var networkSecurityGroupBackendResourceName = 'nsg-${solutionSuffix}-backend' -module networkSecurityGroupBackend 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.backend.${networkSecurityGroupBackendResourceName}', 64) - params: { - name: networkSecurityGroupBackendResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupBastionResourceName = 'nsg-${solutionSuffix}-bastion' -module networkSecurityGroupBastion 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.bastion${networkSecurityGroupBastionResourceName}', 64) - params: { - name: networkSecurityGroupBastionResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'AllowHttpsInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'Internet' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 100 - direction: 'Inbound' - } - } - { - name: 'AllowGatewayManagerInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'GatewayManager' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 110 - direction: 'Inbound' - } - } - { - name: 'AllowLoadBalancerInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'AzureLoadBalancer' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 120 - direction: 'Inbound' - } - } - { - name: 'AllowBastionHostCommunicationInBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: 'VirtualNetwork' - destinationPortRanges: [ - '8080' - '5701' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 130 - direction: 'Inbound' - } - } - { - name: 'DenyAllInBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRange: '*' - destinationAddressPrefix: '*' - access: 'Deny' - priority: 1000 - direction: 'Inbound' - } - } - { - name: 'AllowSshRdpOutBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 100 - direction: 'Outbound' - } - } - { - name: 'AllowAzureCloudCommunicationOutBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRange: '443' - destinationAddressPrefix: 'AzureCloud' - access: 'Allow' - priority: 110 - direction: 'Outbound' - } - } - { - name: 'AllowBastionHostCommunicationOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: 'VirtualNetwork' - destinationPortRanges: [ - '8080' - '5701' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 120 - direction: 'Outbound' - } - } - { - name: 'AllowGetSessionInformationOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: 'Internet' - destinationPortRanges: [ - '80' - '443' - ] - access: 'Allow' - priority: 130 - direction: 'Outbound' - } - } - { - name: 'DenyAllOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - access: 'Deny' - priority: 1000 - direction: 'Outbound' - } - } - ] - } -} - -var networkSecurityGroupAdministrationResourceName = 'nsg-${solutionSuffix}-administration' -module networkSecurityGroupAdministration 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.administration.${networkSecurityGroupAdministrationResourceName}', 64) - params: { - name: networkSecurityGroupAdministrationResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupContainersResourceName = 'nsg-${solutionSuffix}-containers' -module networkSecurityGroupContainers 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.containers.${networkSecurityGroupContainersResourceName}', 64) - params: { - name: networkSecurityGroupContainersResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupWebsiteResourceName = 'nsg-${solutionSuffix}-website' -module networkSecurityGroupWebsite 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.website.${networkSecurityGroupWebsiteResourceName}', 64) - params: { - name: networkSecurityGroupWebsiteResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - // ========== Virtual Network ========== // // WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network // WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking var virtualNetworkResourceName = 'vnet-${solutionSuffix}' -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = if (enablePrivateNetworking) { - name: take('avm.res.network.virtual-network.${virtualNetworkResourceName}', 64) +module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtualNetwork.${solutionSuffix}', 64) params: { - name: virtualNetworkResourceName + name: 'vnet-${solutionSuffix}' location: location tags: tags enableTelemetry: enableTelemetry addressPrefixes: ['10.0.0.0/8'] - subnets: [ - { - name: 'backend' - addressPrefix: '10.0.0.0/27' - networkSecurityGroupResourceId: networkSecurityGroupBackend!.outputs.resourceId - } - { - name: 'administration' - addressPrefix: '10.0.0.32/27' - networkSecurityGroupResourceId: networkSecurityGroupAdministration!.outputs.resourceId - //natGatewayResourceId: natGateway.outputs.resourceId - } - { - // For Azure Bastion resources deployed on or after November 2, 2021, the minimum AzureBastionSubnet size is /26 or larger (/25, /24, etc.). - // https://learn.microsoft.com/en-us/azure/bastion/configuration-settings#subnet - name: 'AzureBastionSubnet' //This exact name is required for Azure Bastion - addressPrefix: '10.0.0.64/26' - networkSecurityGroupResourceId: networkSecurityGroupBastion!.outputs.resourceId - } - { - // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the Container App environment you deploy. This subnet isn't available to other services - // https://learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli#custom-vnw-configuration - name: 'containers' - addressPrefix: '10.0.2.0/23' //subnet of size /23 is required for container app - delegation: 'Microsoft.App/environments' - networkSecurityGroupResourceId: networkSecurityGroupContainers!.outputs.resourceId - privateEndpointNetworkPolicies: 'Enabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - { - // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the App Environment you deploy. This subnet isn't available to other services - // https://learn.microsoft.com/en-us/azure/app-service/overview-vnet-integration#subnet-requirements - name: 'webserverfarm' - addressPrefix: '10.0.4.0/27' //When you're creating subnets in Azure portal as part of integrating with the virtual network, a minimum size of /27 is required - delegation: 'Microsoft.Web/serverfarms' - networkSecurityGroupResourceId: networkSecurityGroupWebsite!.outputs.resourceId - privateEndpointNetworkPolicies: 'Enabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - ] + logAnalyticsWorkspaceId: logAnalyticsWorkspaceResourceId + resourceSuffix: solutionSuffix } } @@ -961,7 +643,7 @@ module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.17.0' = if (e ipConfigurations: [ { name: '${virtualMachineResourceName}-nic01-ipconfig01' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[1] + subnetResourceId: virtualNetwork!.outputs.administrationSubnetResourceId diagnosticSettings: enableMonitoring //WAF aligned configuration for Monitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null @@ -1280,7 +962,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service { name: 'pep-${aiFoundryAiServicesResourceName}' customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId privateDnsZoneGroup: { privateDnsZoneGroupConfigs: [ { @@ -1393,7 +1075,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { ] } service: 'Sql' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId } ] : [] @@ -1438,7 +1120,7 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 // WAF aligned configuration for Private Networking publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment internal: false // Must be false when publicNetworkAccess is'Enabled' - infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?subnetResourceIds[3] : null + infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null // WAF aligned configuration for Monitoring appLogsConfiguration: enableMonitoring ? { @@ -1826,7 +1508,7 @@ module webSite 'modules/web-sites.bicep' = { // WAF aligned configuration for Private Networking vnetRouteAllEnabled: enablePrivateNetworking ? true : false vnetImagePullEnabled: enablePrivateNetworking ? true : false - virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.subnetResourceIds[4] : null + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webserverfarmSubnetResourceId : null publicNetworkAccess: 'Enabled' // Always enabling the public network access for Web App e2eEncryptionEnabled: true } @@ -1884,7 +1566,7 @@ module avmStorageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { } ] } - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId service: 'blob' } ] @@ -2035,7 +1717,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { privateDnsZoneGroupConfigs: [{ privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.keyVault]!.outputs.resourceId }] } service: 'vault' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId } ] : [] diff --git a/infra/modules/virtualNetwork.bicep b/infra/modules/virtualNetwork.bicep new file mode 100644 index 000000000..b9b5f11b6 --- /dev/null +++ b/infra/modules/virtualNetwork.bicep @@ -0,0 +1,374 @@ +/****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +/****************************************************************************************************************************/ +@description('Name of the virtual network.') +param name string + +@description('Azure region to deploy resources.') +param location string = resourceGroup().location + +@description('Required. An Array of 1 or more IP Address Prefixes for the Virtual Network.') +param addressPrefixes array + +@description('An array of subnets to be created within the virtual network. Each subnet can have its own configuration and associated Network Security Group (NSG).') +param subnets subnetType[] = [ + + + { + name:'backend' + addressPrefixes: ['10.0.0.0/27'] + networkSecurityGroup: { + name: 'nsg-backend' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'containers' + addressPrefixes: ['10.0.2.0/23'] + delegation: 'Microsoft.App/environments' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-containers' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'webserverfarm' + addressPrefixes: ['10.0.4.0/27'] + delegation: 'Microsoft.Web/serverfarms' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-webserverfarm' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'administration' + addressPrefixes: ['10.0.0.32/27'] + networkSecurityGroup: { + name: 'nsg-administration' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.0.64/26'] + networkSecurityGroup: { + name: 'nsg-bastion' + securityRules: [ + { + name: 'AllowGatewayManager' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 2702 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: 'GatewayManager' + destinationAddressPrefix: '*' + } + } + { + name: 'AllowHttpsInBound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 2703 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: 'Internet' + destinationAddressPrefix: '*' + } + } + { + name: 'AllowSshRdpOutbound' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 100 + protocol: '*' + sourcePortRange: '*' + destinationPortRanges: ['22', '3389'] + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + } + } + { + name: 'AllowAzureCloudOutbound' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 110 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureCloud' + } + } + ] + } + } +] + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. The resource ID of the Log Analytics Workspace to send diagnostic logs to.') +param logAnalyticsWorkspaceId string + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Required. Suffix for resource naming.') +param resourceSuffix string + +// VM Size Notes: +// 1 B-series VMs (like Standard_B2ms) do not support accelerated networking. +// 2 Pick a VM size that does support accelerated networking (the usual jump-box candidates): +// Standard_DS2_v2 (2 vCPU, 7 GiB RAM, Premium SSD) // The most broadly available (it’s a legacy SKU supported in virtually every region). +// Standard_D2s_v3 (2 vCPU, 8 GiB RAM, Premium SSD) // next most common +// Standard_D2s_v4 (2 vCPU, 8 GiB RAM, Premium SSD) // Newest, so fewer regions availabl + + +// Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) +// | CIDR | # of Addresses | # of /24s | Notes | +// |-----------|---------------|-----------|----------------------------------------| +// | /24 | 256 | 1 | Smallest recommended for Azure subnets | +// | /23 | 512 | 2 | Good for 1-2 workloads per subnet | +// | /22 | 1024 | 4 | Good for 2-4 workloads per subnet | +// | /21 | 2048 | 8 | | +// | /20 | 4096 | 16 | Used for default VNet in this solution | +// | /19 | 8192 | 32 | | +// | /18 | 16384 | 64 | | +// | /17 | 32768 | 128 | | +// | /16 | 65536 | 256 | | +// | /15 | 131072 | 512 | | +// | /14 | 262144 | 1024 | | +// | /13 | 524288 | 2048 | | +// | /12 | 1048576 | 4096 | | +// | /11 | 2097152 | 8192 | | +// | /10 | 4194304 | 16384 | | +// | /9 | 8388608 | 32768 | | +// | /8 | 16777216 | 65536 | | +// +// Best Practice Notes: +// - Use /24 as the minimum subnet size for Azure (smaller subnets are not supported for most services). +// - Plan for future growth: allocate larger address spaces (e.g., /20 or /21 for VNets) to allow for new subnets. +// - Avoid overlapping address spaces with on-premises or other VNets. +// - Use contiguous, non-overlapping ranges for subnets. +// - Document subnet usage and purpose in code comments. +// - For AVM modules, ensure only one delegation per subnet and leave delegations empty if not required. + +// 1. Create NSGs for subnets +// using AVM Network Security Group module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group + +@batchSize(1) +module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ + for (subnet, i) in subnets: if (!empty(subnet.?networkSecurityGroup)) { + name: take('avm.res.network.network-security-group.${subnet.?networkSecurityGroup.name}.${resourceSuffix}', 64) + params: { + name: '${subnet.?networkSecurityGroup.name}-${resourceSuffix}' + location: location + securityRules: subnet.?networkSecurityGroup.securityRules + tags: tags + enableTelemetry: enableTelemetry + } + } +] + +// 2. Create VNet and subnets, with subnets associated with corresponding NSGs +// using AVM Virtual Network module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network + +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { + name: take('avm.res.network.virtual-network.${name}', 64) + params: { + name: name + location: location + addressPrefixes: addressPrefixes + subnets: [ + for (subnet, i) in subnets: { + name: subnet.name + addressPrefixes: subnet.?addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null + privateEndpointNetworkPolicies: subnet.?privateEndpointNetworkPolicies + privateLinkServiceNetworkPolicies: subnet.?privateLinkServiceNetworkPolicies + delegation: subnet.?delegation + } + ] + diagnosticSettings: [ + { + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + tags: tags + enableTelemetry: enableTelemetry + } +} + +output name string = virtualNetwork.outputs.name +output resourceId string = virtualNetwork.outputs.resourceId + +// combined output array that holds subnet details along with NSG information +output subnets subnetOutputType[] = [ + for (subnet, i) in subnets: { + name: subnet.name + resourceId: virtualNetwork.outputs.subnetResourceIds[i] + nsgName: !empty(subnet.?networkSecurityGroup) ? subnet.?networkSecurityGroup.name : null + nsgResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null + } +] + +// Dynamic outputs for individual subnets for backward compatibility +output backendSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'backend') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'backend')] : '' +output containerSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'containers') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'containers')] : '' +output administrationSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'administration') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'administration')] : '' +output webserverfarmSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'webserverfarm') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'webserverfarm')] : '' +output bastionSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'AzureBastionSubnet') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'AzureBastionSubnet')] : '' + +@export() +@description('Custom type definition for subnet resource information as output') +type subnetOutputType = { + @description('The name of the subnet.') + name: string + + @description('The resource ID of the subnet.') + resourceId: string + + @description('The name of the associated network security group, if any.') + nsgName: string? + + @description('The resource ID of the associated network security group, if any.') + nsgResourceId: string? +} + +@export() +@description('Custom type definition for subnet configuration') +type subnetType = { + @description('Required. The Name of the subnet resource.') + name: string + + @description('Required. Prefixes for the subnet.') // Required to ensure at least one prefix is provided + addressPrefixes: string[] + + @description('Optional. The delegation to enable on the subnet.') + delegation: string? + + @description('Optional. enable or disable apply network policies on private endpoint in the subnet.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + + @description('Optional. Enable or disable apply network policies on private link service in the subnet.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + + @description('Optional. Network Security Group configuration for the subnet.') + networkSecurityGroup: networkSecurityGroupType? + + @description('Optional. The resource ID of the route table to assign to the subnet.') + routeTableResourceId: string? + + @description('Optional. An array of service endpoint policies.') + serviceEndpointPolicies: object[]? + + @description('Optional. The service endpoints to enable on the subnet.') + serviceEndpoints: string[]? + + @description('Optional. Set this property to false to disable default outbound connectivity for all VMs in the subnet. This property can only be set at the time of subnet creation and cannot be updated for an existing subnet.') + defaultOutboundAccess: bool? +} + +@export() +@description('Custom type definition for network security group configuration') +type networkSecurityGroupType = { + @description('Required. The name of the network security group.') + name: string + + @description('Required. The security rules for the network security group.') + securityRules: object[] +} diff --git a/src/backend/common/config/app_config.py b/src/backend/common/config/app_config.py index f359b89e3..5626752c1 100644 --- a/src/backend/common/config/app_config.py +++ b/src/backend/common/config/app_config.py @@ -120,9 +120,7 @@ def get_azure_credential(self, client_id=None): Credential object: Either DefaultAzureCredential or ManagedIdentityCredential. """ if self.APP_ENV == "dev": - return ( - DefaultAzureCredential() - ) # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + return DefaultAzureCredential() # CodeQL [SM05139]: DefaultAzureCredential is safe here else: return ManagedIdentityCredential(client_id=client_id) diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 11e83c53d..a86b35c50 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -396,8 +396,8 @@ async def plan_approval( orchestration_config and human_feedback.m_plan_id in orchestration_config.approvals ): - orchestration_config.approvals[human_feedback.m_plan_id] = ( - human_feedback.approved + orchestration_config.set_approval_result( + human_feedback.m_plan_id, human_feedback.approved ) # orchestration_config.plans[human_feedback.m_plan_id][ # "plan_id" @@ -528,10 +528,10 @@ async def user_clarification( orchestration_config and human_feedback.request_id in orchestration_config.clarifications ): - orchestration_config.clarifications[human_feedback.request_id] = ( - human_feedback.answer + # Use the new event-driven method to set clarification result + orchestration_config.set_clarification_result( + human_feedback.request_id, human_feedback.answer ) - try: result = await PlanService.handle_human_clarification( human_feedback, user_id diff --git a/src/backend/v3/callbacks/response_handlers.py b/src/backend/v3/callbacks/response_handlers.py index fb91dd9c0..51a4e2e84 100644 --- a/src/backend/v3/callbacks/response_handlers.py +++ b/src/backend/v3/callbacks/response_handlers.py @@ -6,7 +6,7 @@ import asyncio import logging import time - +import re from semantic_kernel.contents import ChatMessageContent, StreamingChatMessageContent from v3.config.settings import connection_config from v3.models.messages import ( @@ -18,6 +18,24 @@ ) +def clean_citations(text: str) -> str: + """Remove citation markers from agent responses while preserving formatting.""" + if not text: + return text + + # Remove citation patterns like [9:0|source], [9:1|source], etc. + text = re.sub(r'\[\d+:\d+\|source\]', '', text) + + # Remove other common citation pattern + text = re.sub(r'\[\s*source\s*\]', '', text, flags=re.IGNORECASE) + text = re.sub(r'\[\d+\]', '', text) + text = re.sub(r'【[^】]*】', '', text) # Unicode brackets + text = re.sub(r'\(source:[^)]*\)', '', text, flags=re.IGNORECASE) + text = re.sub(r'\[source:[^\]]*\]', '', text, flags=re.IGNORECASE) + + return text + + def agent_response_callback(message: ChatMessageContent, user_id: str = None) -> None: """Observer function to print detailed information about streaming messages.""" # import sys @@ -55,7 +73,7 @@ def agent_response_callback(message: ChatMessageContent, user_id: str = None) -> final_message = AgentMessage( agent_name=agent_name, timestamp=time.time() or "", - content=message.content or "", + content=clean_citations(message.content) or "", ) asyncio.create_task( @@ -80,7 +98,7 @@ async def streaming_agent_response_callback( try: message = AgentMessageStreaming( agent_name=streaming_message.name or "Unknown Agent", - content=streaming_message.content, + content=clean_citations(streaming_message.content), is_final=is_final, ) await connection_config.send_status_update_async( diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index 1dcbfbc67..5958eb4f3 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -6,7 +6,7 @@ import asyncio import json import logging -from typing import Dict +from typing import Dict, Optional from common.config.app_config import config from common.models.messages_kernel import TeamConfiguration @@ -86,10 +86,159 @@ def __init__(self): 20 # Maximum number of replanning rounds 20 needed to accommodate complex tasks ) + # Event-driven notification system for approvals and clarifications + self._approval_events: Dict[str, asyncio.Event] = {} + self._clarification_events: Dict[str, asyncio.Event] = {} + + # Default timeout for waiting operations (5 minutes) + self.default_timeout: float = 300.0 + def get_current_orchestration(self, user_id: str) -> MagenticOrchestration: """get existing orchestration instance.""" return self.orchestrations.get(user_id, None) + def set_approval_pending(self, plan_id: str) -> None: + """Set an approval as pending and create an event for it.""" + self.approvals[plan_id] = None + if plan_id not in self._approval_events: + self._approval_events[plan_id] = asyncio.Event() + else: + # Clear existing event to reset state + self._approval_events[plan_id].clear() + + def set_approval_result(self, plan_id: str, approved: bool) -> None: + """Set the approval result and trigger the event.""" + self.approvals[plan_id] = approved + if plan_id in self._approval_events: + self._approval_events[plan_id].set() + + async def wait_for_approval(self, plan_id: str, timeout: Optional[float] = None) -> bool: + """ + Wait for an approval decision with timeout. + + Args: + plan_id: The plan ID to wait for + timeout: Timeout in seconds (defaults to default_timeout) + + Returns: + The approval decision (True/False) + + Raises: + asyncio.TimeoutError: If timeout is exceeded + KeyError: If plan_id is not found in approvals + """ + if timeout is None: + timeout = self.default_timeout + + if plan_id not in self.approvals: + raise KeyError(f"Plan ID {plan_id} not found in approvals") + + if self.approvals[plan_id] is not None: + # Already has a result + return self.approvals[plan_id] + + if plan_id not in self._approval_events: + self._approval_events[plan_id] = asyncio.Event() + + try: + await asyncio.wait_for(self._approval_events[plan_id].wait(), timeout=timeout) + return self.approvals[plan_id] + except asyncio.TimeoutError: + # Clean up on timeout + self.cleanup_approval(plan_id) + raise + except asyncio.CancelledError: + # Handle task cancellation gracefully + logger.debug(f"Approval request {plan_id} was cancelled") + raise + except Exception as e: + # Handle any other unexpected errors + logger.error(f"Unexpected error waiting for approval {plan_id}: {e}") + raise + finally: + # Ensure cleanup happens regardless of how the try block exits + # Only cleanup if the approval is still pending (None) to avoid + # cleaning up successful approvals + if plan_id in self.approvals and self.approvals[plan_id] is None: + self.cleanup_approval(plan_id) + + def set_clarification_pending(self, request_id: str) -> None: + """Set a clarification as pending and create an event for it.""" + self.clarifications[request_id] = None + if request_id not in self._clarification_events: + self._clarification_events[request_id] = asyncio.Event() + else: + # Clear existing event to reset state + self._clarification_events[request_id].clear() + + def set_clarification_result(self, request_id: str, answer: str) -> None: + """Set the clarification response and trigger the event.""" + self.clarifications[request_id] = answer + if request_id in self._clarification_events: + self._clarification_events[request_id].set() + + async def wait_for_clarification(self, request_id: str, timeout: Optional[float] = None) -> str: + """ + Wait for a clarification response with timeout. + + Args: + request_id: The request ID to wait for + timeout: Timeout in seconds (defaults to default_timeout) + + Returns: + The clarification response + + Raises: + asyncio.TimeoutError: If timeout is exceeded + KeyError: If request_id is not found in clarifications + """ + if timeout is None: + timeout = self.default_timeout + + if request_id not in self.clarifications: + raise KeyError(f"Request ID {request_id} not found in clarifications") + + if self.clarifications[request_id] is not None: + # Already has a result + return self.clarifications[request_id] + + if request_id not in self._clarification_events: + self._clarification_events[request_id] = asyncio.Event() + + try: + await asyncio.wait_for(self._clarification_events[request_id].wait(), timeout=timeout) + return self.clarifications[request_id] + except asyncio.TimeoutError: + # Clean up on timeout + self.cleanup_clarification(request_id) + raise + except asyncio.CancelledError: + # Handle task cancellation gracefully + logger.debug(f"Clarification request {request_id} was cancelled") + raise + except Exception as e: + # Handle any other unexpected errors + logger.error(f"Unexpected error waiting for clarification {request_id}: {e}") + raise + finally: + # Ensure cleanup happens regardless of how the try block exits + # Only cleanup if the clarification is still pending (None) to avoid + # cleaning up successful clarifications + if request_id in self.clarifications and self.clarifications[request_id] is None: + self.cleanup_clarification(request_id) + + def cleanup_approval(self, plan_id: str) -> None: + """Clean up approval resources.""" + self.approvals.pop(plan_id, None) + if plan_id in self._approval_events: + del self._approval_events[plan_id] + + def cleanup_clarification(self, request_id: str) -> None: + """Clean up clarification resources.""" + self.clarifications.pop(request_id, None) + if request_id in self._clarification_events: + del self._clarification_events[request_id] + class ConnectionConfig: """Connection manager for WebSocket connections.""" diff --git a/src/backend/v3/magentic_agents/proxy_agent.py b/src/backend/v3/magentic_agents/proxy_agent.py index db952cc55..02cd90b79 100644 --- a/src/backend/v3/magentic_agents/proxy_agent.py +++ b/src/backend/v3/magentic_agents/proxy_agent.py @@ -3,6 +3,7 @@ import asyncio import logging +import time import uuid from collections.abc import AsyncIterable from typing import AsyncIterator, Optional @@ -30,6 +31,9 @@ from v3.models.messages import (UserClarificationRequest, UserClarificationResponse, WebsocketMessageType) +# Initialize logger for the module +logger = logging.getLogger(__name__) + class DummyAgentThread(AgentThread): """Dummy thread implementation for proxy agent.""" @@ -185,10 +189,16 @@ async def invoke( clarification_message.request_id ) - if not human_response: - human_response = "No additional clarification provided." + # Handle silent timeout/cancellation + if human_response is None: + # Process was terminated silently - don't yield any response + logger.debug("Clarification process terminated silently - ending invoke") + return + + # Extract the answer from the response + answer = human_response.answer if human_response else "No additional clarification provided." - response = f"Human clarification: {human_response}" + response = f"Human clarification: {answer}" chat_message = self._create_message_content(response, thread.id) @@ -242,10 +252,16 @@ async def invoke_stream( clarification_message.request_id ) - if not human_response: - human_response = "No additional clarification provided." + # Handle silent timeout/cancellation + if human_response is None: + # Process was terminated silently - don't yield any response + logger.debug("Clarification process terminated silently - ending invoke_stream") + return + + # Extract the answer from the response + answer = human_response.answer if human_response else "No additional clarification provided." - response = f"Human clarification: {human_response}" + response = f"Human clarification: {answer}" chat_message = self._create_message_content(response, thread.id) @@ -254,16 +270,86 @@ async def invoke_stream( async def _wait_for_user_clarification( self, request_id: str ) -> Optional[UserClarificationResponse]: - """Wait for user clarification response.""" - # To do: implement timeout and error handling - if request_id not in orchestration_config.clarifications: - orchestration_config.clarifications[request_id] = None - while orchestration_config.clarifications[request_id] is None: - await asyncio.sleep(0.2) - return UserClarificationResponse( - request_id=request_id, - answer=orchestration_config.clarifications[request_id], - ) + """ + Wait for user clarification response using event-driven pattern with timeout handling. + + Args: + request_id: The request ID to wait for clarification + + Returns: + UserClarificationResponse: Clarification result with request ID and answer + + Raises: + asyncio.TimeoutError: If timeout is exceeded (300 seconds default) + """ + # logger.info(f"Waiting for user clarification for request: {request_id}") + + # Initialize clarification as pending using the new event-driven method + orchestration_config.set_clarification_pending(request_id) + + try: + # Wait for clarification with timeout using the new event-driven method + answer = await orchestration_config.wait_for_clarification(request_id) + + # logger.info(f"Clarification received for request {request_id}: {answer}") + return UserClarificationResponse( + request_id=request_id, + answer=answer, + ) + except asyncio.TimeoutError: + # Enhanced timeout handling - notify user via WebSocket and cleanup + logger.debug(f"Clarification timeout for request {request_id} - notifying user and terminating process") + + # Create timeout notification message + from v3.models.messages import TimeoutNotification, WebsocketMessageType + timeout_notification = TimeoutNotification( + timeout_type="clarification", + request_id=request_id, + message=f"User clarification request timed out after {orchestration_config.default_timeout} seconds. Please try again.", + timestamp=time.time(), + timeout_duration=orchestration_config.default_timeout + ) + + # Send timeout notification to user via WebSocket + try: + await connection_config.send_status_update_async( + message=timeout_notification, + user_id=self.user_id, + message_type=WebsocketMessageType.TIMEOUT_NOTIFICATION, + ) + logger.info(f"Timeout notification sent to user {self.user_id} for clarification {request_id}") + except Exception as e: + logger.error(f"Failed to send timeout notification: {e}") + + # Clean up this specific request + orchestration_config.cleanup_clarification(request_id) + + # Return None to indicate silent termination + # The timeout naturally stops this specific wait operation without affecting other tasks + return None + + except KeyError as e: + # Silent error handling for invalid request IDs + logger.debug(f"Request ID not found: {e} - terminating process silently") + return None + + except asyncio.CancelledError: + # Handle task cancellation gracefully + logger.debug(f"Clarification request {request_id} was cancelled") + orchestration_config.cleanup_clarification(request_id) + return None + + except Exception as e: + # Silent error handling for unexpected errors + logger.debug(f"Unexpected error waiting for clarification: {e} - terminating process silently") + orchestration_config.cleanup_clarification(request_id) + return None + finally: + # Ensure cleanup happens for any incomplete requests + # This provides an additional safety net for resource cleanup + if (request_id in orchestration_config.clarifications and orchestration_config.clarifications[request_id] is None): + logger.debug(f"Final cleanup for pending clarification request {request_id}") + orchestration_config.cleanup_clarification(request_id) async def get_response(self, chat_history, **kwargs): """Get response from the agent - required by Agent base class.""" diff --git a/src/backend/v3/models/messages.py b/src/backend/v3/models/messages.py index 8eb4187c8..4537820d2 100644 --- a/src/backend/v3/models/messages.py +++ b/src/backend/v3/models/messages.py @@ -176,6 +176,27 @@ class AgentMessageResponse: streaming_message: str = None +@dataclass(slots=True) +class TimeoutNotification: + """Notification sent to user when session timeout occurs.""" + + timeout_type: str # "approval" or "clarification" + request_id: str # plan_id or request_id that timed out + message: str # Human-readable timeout message + timestamp: float # When the timeout occurred + timeout_duration: float # How long we waited before timing out + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "timeout_type": self.timeout_type, + "request_id": self.request_id, + "message": self.message, + "timestamp": self.timestamp, + "timeout_duration": self.timeout_duration + } + + class WebsocketMessageType(str, Enum): """Types of WebSocket messages.""" @@ -192,3 +213,4 @@ class WebsocketMessageType(str, Enum): USER_CLARIFICATION_REQUEST = "user_clarification_request" USER_CLARIFICATION_RESPONSE = "user_clarification_response" FINAL_RESULT_MESSAGE = "final_result_message" + TIMEOUT_NOTIFICATION = "timeout_notification" diff --git a/src/backend/v3/orchestration/human_approval_manager.py b/src/backend/v3/orchestration/human_approval_manager.py index 1efd6c443..bfba4befe 100644 --- a/src/backend/v3/orchestration/human_approval_manager.py +++ b/src/backend/v3/orchestration/human_approval_manager.py @@ -206,16 +206,88 @@ async def create_progress_ledger( async def _wait_for_user_approval( self, m_plan_id: Optional[str] = None ) -> Optional[messages.PlanApprovalResponse]: - """Wait for user approval response.""" - - # To do: implement timeout and error handling - if m_plan_id not in orchestration_config.approvals: - orchestration_config.approvals[m_plan_id] = None - while orchestration_config.approvals[m_plan_id] is None: - await asyncio.sleep(0.2) - return messages.PlanApprovalResponse( - approved=orchestration_config.approvals[m_plan_id], m_plan_id=m_plan_id - ) + """ + Wait for user approval response using event-driven pattern with timeout handling. + + Args: + m_plan_id: The plan ID to wait for approval + + Returns: + PlanApprovalResponse: Approval result with approved status and plan ID + + Raises: + asyncio.TimeoutError: If timeout is exceeded (300 seconds default) + """ + logger.info(f"Waiting for user approval for plan: {m_plan_id}") + + if not m_plan_id: + logger.error("No plan ID provided for approval") + return messages.PlanApprovalResponse(approved=False, m_plan_id=m_plan_id) + + # Initialize approval as pending using the new event-driven method + orchestration_config.set_approval_pending(m_plan_id) + + try: + # Wait for approval with timeout using the new event-driven method + approved = await orchestration_config.wait_for_approval(m_plan_id) + + logger.info(f"Approval received for plan {m_plan_id}: {approved}") + return messages.PlanApprovalResponse( + approved=approved, m_plan_id=m_plan_id + ) + except asyncio.TimeoutError: + # Enhanced timeout handling - notify user via WebSocket and cleanup + logger.debug(f"Approval timeout for plan {m_plan_id} - notifying user and terminating process") + + # Create timeout notification message + timeout_message = messages.TimeoutNotification( + timeout_type="approval", + request_id=m_plan_id, + message=f"Plan approval request timed out after {orchestration_config.default_timeout} seconds. Please try again.", + timestamp=asyncio.get_event_loop().time(), + timeout_duration=orchestration_config.default_timeout + ) + + # Send timeout notification to user via WebSocket + try: + await connection_config.send_status_update_async( + message=timeout_message, + user_id=self.current_user_id, + message_type=messages.WebsocketMessageType.TIMEOUT_NOTIFICATION, + ) + logger.info(f"Timeout notification sent to user {self.current_user_id} for plan {m_plan_id}") + except Exception as e: + logger.error(f"Failed to send timeout notification: {e}") + + # Clean up this specific request + orchestration_config.cleanup_approval(m_plan_id) + + # Return None to indicate silent termination + # The timeout naturally stops this specific wait operation without affecting other tasks + return None + + except KeyError as e: + # Silent error handling for invalid plan IDs + logger.debug(f"Plan ID not found: {e} - terminating process silently") + return None + + except asyncio.CancelledError: + # Handle task cancellation gracefully + logger.debug(f"Approval request {m_plan_id} was cancelled") + orchestration_config.cleanup_approval(m_plan_id) + return None + + except Exception as e: + # Silent error handling for unexpected errors + logger.debug(f"Unexpected error waiting for approval: {e} - terminating process silently") + orchestration_config.cleanup_approval(m_plan_id) + return None + finally: + # Ensure cleanup happens for any incomplete requests + # This provides an additional safety net for resource cleanup + if (m_plan_id in orchestration_config.approvals and orchestration_config.approvals[m_plan_id] is None): + logger.debug(f"Final cleanup for pending approval plan {m_plan_id}") + orchestration_config.cleanup_approval(m_plan_id) async def prepare_final_answer( self, magentic_context: MagenticContext diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index e34126679..7db458fee 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -5,7 +5,6 @@ import uuid from typing import List, Optional -from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential from common.config.app_config import config from common.models.messages_kernel import TeamConfiguration from semantic_kernel.agents.orchestration.magentic import MagenticOrchestration @@ -46,7 +45,7 @@ async def init_orchestration( max_tokens=4000, temperature=0.1 ) - credential = SyncDefaultAzureCredential() + credential = config.get_azure_credential(client_id=config.AZURE_CLIENT_ID) def get_token(): token = credential.get_token("https://cognitiveservices.azure.com/.default") @@ -120,7 +119,9 @@ async def run_orchestration(self, user_id, input_task) -> None: """Run the orchestration with user input loop.""" job_id = str(uuid.uuid4()) - orchestration_config.approvals[job_id] = None + + # Use the new event-driven method to set approval as pending + orchestration_config.set_approval_pending(job_id) magentic_orchestration = orchestration_config.get_current_orchestration(user_id) diff --git a/src/frontend/src/services/PlanDataService.tsx b/src/frontend/src/services/PlanDataService.tsx index 18770a114..e2c2b9e02 100644 --- a/src/frontend/src/services/PlanDataService.tsx +++ b/src/frontend/src/services/PlanDataService.tsx @@ -383,7 +383,7 @@ export class PlanDataService { const facts = body - .match(/facts="([^"]*(?:\\.[^"]*)*)"/)?.[1] + .match(/facts="((?:[^"\\]|\\.)*)"/)?.[1] ?.replace(/\\n/g, '\n') .replace(/\\"/g, '"') || ''; @@ -792,7 +792,7 @@ export class PlanDataService { if (!source) return null; // question=( "...") OR ('...') - const questionRegex = /question=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)')/; + const questionRegex = /question=(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/; const qMatch = source.match(questionRegex); if (!qMatch) return null; diff --git a/src/mcp_server/pyproject.toml b/src/mcp_server/pyproject.toml index 04dbe4ed3..4171f90f0 100644 --- a/src/mcp_server/pyproject.toml +++ b/src/mcp_server/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "azure-identity==1.19.0", "pydantic==2.11.7", "pydantic-settings==2.6.1", - "python-multipart==0.0.17", + "python-multipart==0.0.18", "httpx==0.28.1", ]