diff --git a/actions/setup/sh/start_cli_proxy.sh b/actions/setup/sh/start_cli_proxy.sh index fe6f8f638bd..409ed862090 100644 --- a/actions/setup/sh/start_cli_proxy.sh +++ b/actions/setup/sh/start_cli_proxy.sh @@ -40,7 +40,12 @@ if [ -n "$POLICY" ]; then POLICY_ARGS=(--policy "$POLICY") fi -docker run -d --name awmg-cli-proxy --network host \ +DOCKER_NETWORK_ARGS=(--network host) +if [ "${GH_AW_NETWORK_ISOLATION:-false}" = "true" ]; then + DOCKER_NETWORK_ARGS=(--network bridge -p 127.0.0.1:18443:18443) +fi + +docker run -d --name awmg-cli-proxy "${DOCKER_NETWORK_ARGS[@]}" \ --user "$(id -u):$(id -g)" \ -e GH_TOKEN \ -e GITHUB_SERVER_URL \ diff --git a/actions/setup/sh/start_difc_proxy.sh b/actions/setup/sh/start_difc_proxy.sh index 6a60995fa91..680abb0ca58 100644 --- a/actions/setup/sh/start_difc_proxy.sh +++ b/actions/setup/sh/start_difc_proxy.sh @@ -39,7 +39,15 @@ mkdir -p "$PROXY_LOG_DIR" "$MCP_LOG_DIR" echo "Starting DIFC proxy container: $CONTAINER_IMAGE" -docker run -d --name awmg-proxy --network host \ +# Remove any existing container to avoid name conflicts on cancelled/retried jobs. +docker rm -f awmg-proxy 2>/dev/null || true + +DOCKER_NETWORK_ARGS=(--network host) +if [ "${GH_AW_NETWORK_ISOLATION:-false}" = "true" ]; then + DOCKER_NETWORK_ARGS=(--network bridge -p 127.0.0.1:18443:18443) +fi + +docker run -d --name awmg-proxy "${DOCKER_NETWORK_ARGS[@]}" \ --user "$(id -u):$(id -g)" \ -e GH_TOKEN \ -e GITHUB_SERVER_URL \ diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index be645d4553d..30423670e3d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3257,6 +3257,10 @@ "enum": ["github.com", "ghes", "ghec", "ghec-self-hosted"], "description": "AWF platform.type override. Declares the GitHub deployment type so AWF can apply deterministic Copilot auth behavior without relying on host heuristics. Omit to let AWF use its default host heuristic behavior." }, + "network-isolation": { + "type": "boolean", + "description": "Enable AWF network topology egress mode (--network-isolation). In this mode, MCP sidecars run as bridge containers and AWF attaches them to its internal awf-net network." + }, "command": { "type": "string", "x-internal": true, diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c14ed72abe1..c2b3df65030 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -187,6 +187,14 @@ type AWFNetworkConfig struct { // BlockDomains is the list of explicitly blocked egress domains. // Maps to: --block-domains BlockDomains []string `json:"blockDomains,omitempty"` + + // Isolation enables topology-based egress isolation mode. + // Maps to: --network-isolation + Isolation bool `json:"isolation,omitempty"` + + // TopologyAttach lists container names AWF should attach to awf-net. + // Maps to: --topology-attach (repeatable) + TopologyAttach []string `json:"topologyAttach,omitempty"` } // AWFPlatformConfig is the "platform" section of the AWF config file. @@ -361,6 +369,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { } } + if isAWFNetworkIsolationEnabled(config.WorkflowData) { + if awfConfig.Network == nil { + awfConfig.Network = &AWFNetworkConfig{} + } + awfConfig.Network.Isolation = true + awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData) + awfConfigLog.Printf("Network section: isolation enabled with %d topology attachments", len(awfConfig.Network.TopologyAttach)) + } + if platformType := extractPlatformType(config.WorkflowData); platformType != "" { awfConfig.Platform = &AWFPlatformConfig{Type: platformType} awfConfigLog.Printf("Platform section: type=%s", platformType) @@ -503,6 +520,18 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { return jsonStr, nil } +// buildAWFTopologyAttachList returns container names that AWF should attach to +// the internal awf-net network when network isolation mode is enabled. +// The list always includes the MCP gateway and conditionally includes the +// host-started CLI proxy sidecar when gh-proxy mode is active. +func buildAWFTopologyAttachList(workflowData *WorkflowData) []string { + targets := []string{"awmg-mcpg"} + if isCliProxyNeeded(workflowData) { + targets = append(targets, "awmg-cli-proxy") + } + return targets +} + // splitDomainList splits a comma-separated domain string into a deduplicated // slice. Empty entries are ignored. The order of the original list is preserved for // non-duplicate entries; this keeps the allow-list deterministic. diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index b228a28c99a..9db4f12bb22 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -102,6 +102,31 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, "ads.example.com", "should include the blocked domain") }) + t.Run("network isolation emits isolation and topologyAttach", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + NetworkIsolation: true, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err, "BuildAWFConfigJSON should not return an error") + + assert.Contains(t, jsonStr, `"isolation":true`, "should enable network isolation") + assert.Contains(t, jsonStr, `"topologyAttach":["awmg-mcpg"]`, "should attach MCP gateway container to awf-net") + }) + t.Run("openai API target is included in apiProxy targets", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "codex", @@ -1559,3 +1584,38 @@ func TestBuildAWFCommand_WritesAgentCLIStartTimestamp(t *testing.T) { }) } } + +func TestBuildAWFTopologyAttachList(t *testing.T) { + t.Run("includes only MCP gateway when cli proxy is not needed", func(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "toolsets": []string{"repos"}, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + + targets := buildAWFTopologyAttachList(workflowData) + assert.Equal(t, []string{"awmg-mcpg"}, targets) + }) + + t.Run("includes CLI proxy when gh-proxy mode is enabled", func(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "mode": "gh-proxy", + "toolsets": []string{"repos"}, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + } + + targets := buildAWFTopologyAttachList(workflowData) + assert.Equal(t, []string{"awmg-mcpg", "awmg-cli-proxy"}, targets) + }) +} diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index cea08966734..e2f14450a91 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -611,25 +611,29 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled") } - // Always add --enable-host-access: needed for the API proxy sidecar - // (to reach host.docker.internal:) and for MCP gateway communication - awfArgs = append(awfArgs, "--enable-host-access") - awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway") - - // AWF's --enable-host-access defaults to ports 80,443. The MCP gateway now - // listens on port 8080 (non-privileged), so we must explicitly allow it - // when AWF supports --allow-host-ports. - if awfSupportsAllowHostPorts(firewallConfig) { - mcpGatewayPort := int(DefaultMCPGatewayPort) - if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && - config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 { - mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port - } - hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort) - awfArgs = append(awfArgs, "--allow-host-ports", hostPorts) - awfHelpersLog.Printf("Added --allow-host-ports %s for MCP gateway access", hostPorts) + if isAWFNetworkIsolationEnabled(config.WorkflowData) { + awfHelpersLog.Print("Skipping host-access flags: sandbox.agent.network-isolation is enabled") } else { - awfHelpersLog.Printf("Skipping --allow-host-ports: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAllowHostPortsMinVersion) + // Always add --enable-host-access: needed for the API proxy sidecar + // (to reach host.docker.internal:) and for MCP gateway communication + awfArgs = append(awfArgs, "--enable-host-access") + awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway") + + // AWF's --enable-host-access defaults to ports 80,443. The MCP gateway now + // listens on port 8080 (non-privileged), so we must explicitly allow it + // when AWF supports --allow-host-ports. + if awfSupportsAllowHostPorts(firewallConfig) { + mcpGatewayPort := int(DefaultMCPGatewayPort) + if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && + config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 { + mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port + } + hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort) + awfArgs = append(awfArgs, "--allow-host-ports", hostPorts) + awfHelpersLog.Printf("Added --allow-host-ports %s for MCP gateway access", hostPorts) + } else { + awfHelpersLog.Printf("Skipping --allow-host-ports: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAllowHostPortsMinVersion) + } } // Skip pulling images since they are pre-downloaded @@ -641,7 +645,11 @@ func BuildAWFArgs(config AWFCommandConfig) []string { // (firewall v0.25.17+). if isGitHubCLIModeEnabled(config.WorkflowData) { if awfSupportsCliProxy(firewallConfig) { - awfArgs = append(awfArgs, "--difc-proxy-host", "host.docker.internal:18443") + difcProxyHost := "host.docker.internal:18443" + if isAWFNetworkIsolationEnabled(config.WorkflowData) { + difcProxyHost = "awmg-cli-proxy:18443" + } + awfArgs = append(awfArgs, "--difc-proxy-host", difcProxyHost) awfArgs = append(awfArgs, "--difc-proxy-ca-cert", constants.TmpDIFCProxyTLSCACert) awfHelpersLog.Print("Added --difc-proxy-host and --difc-proxy-ca-cert for CLI proxy sidecar") } else { diff --git a/pkg/workflow/awf_helpers_test.go b/pkg/workflow/awf_helpers_test.go index bce269c842d..8e183458cd4 100644 --- a/pkg/workflow/awf_helpers_test.go +++ b/pkg/workflow/awf_helpers_test.go @@ -638,6 +638,32 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports for AWF versions below minimum support") }) + + t.Run("skips host-access flags when network isolation is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + NetworkIsolation: true, + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--enable-host-access", "Should skip --enable-host-access in network isolation mode") + assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports in network isolation mode") + }) } // TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs @@ -1123,6 +1149,36 @@ func TestBuildAWFArgsCliProxy(t *testing.T) { assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") }) + t.Run("uses internal cli proxy host when network isolation is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + NetworkIsolation: true, + }, + }, + Features: map[string]any{"cli-proxy": true}, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled") + assert.Contains(t, argsStr, "awmg-cli-proxy:18443", "Should use internal awf-net CLI proxy address in isolation mode") + assert.NotContains(t, argsStr, "host.docker.internal:18443", "Should not use host.docker.internal in isolation mode") + }) + t.Run("does not include cli-proxy flags for copilot by default", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", diff --git a/pkg/workflow/compiler_difc_proxy.go b/pkg/workflow/compiler_difc_proxy.go index 77912f9d1c3..207e91a0864 100644 --- a/pkg/workflow/compiler_difc_proxy.go +++ b/pkg/workflow/compiler_difc_proxy.go @@ -249,6 +249,9 @@ func (c *Compiler) buildStartDIFCProxyStepYAML(data *WorkflowData) string { sb.WriteString(" env:\n") fmt.Fprintf(&sb, " GH_TOKEN: %s\n", effectiveToken) sb.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n") + if isAWFNetworkIsolationEnabled(data) { + sb.WriteString(" GH_AW_NETWORK_ISOLATION: 'true'\n") + } // Store policy and image in env vars to avoid shell-quoting issues with // inline JSON arguments and to keep the run: command clean. fmt.Fprintf(&sb, " DIFC_PROXY_POLICY: '%s'\n", policyJSON) @@ -519,6 +522,9 @@ func (c *Compiler) buildStartCliProxyStepYAML(data *WorkflowData) string { sb.WriteString(" env:\n") fmt.Fprintf(&sb, " GH_TOKEN: %s\n", effectiveToken) sb.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n") + if isAWFNetworkIsolationEnabled(data) { + sb.WriteString(" GH_AW_NETWORK_ISOLATION: 'true'\n") + } fmt.Fprintf(&sb, " CLI_PROXY_POLICY: '%s'\n", policyJSON) fmt.Fprintf(&sb, " CLI_PROXY_IMAGE: '%s'\n", containerImage) sb.WriteString(" run: |\n") diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index d675415842c..6181a6ad121 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -115,6 +115,14 @@ func getAgentConfig(workflowData *WorkflowData) *AgentSandboxConfig { return workflowData.SandboxConfig.Agent } +func isAWFNetworkIsolationEnabled(workflowData *WorkflowData) bool { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return false + } + return agentConfig.NetworkIsolation +} + // enableFirewallByDefaultForCopilot enables firewall by default for copilot and codex engines // when network restrictions are present but no explicit firewall configuration exists // and no SRT sandbox is configured (SRT and AWF are mutually exclusive) diff --git a/pkg/workflow/firewall_default_enablement_test.go b/pkg/workflow/firewall_default_enablement_test.go index 32ed3cf9df6..90aea606014 100644 --- a/pkg/workflow/firewall_default_enablement_test.go +++ b/pkg/workflow/firewall_default_enablement_test.go @@ -537,3 +537,45 @@ func TestStrictModeFirewallValidation(t *testing.T) { } }) } + +func TestIsAWFNetworkIsolationEnabled(t *testing.T) { + t.Run("returns false when workflow data is nil", func(t *testing.T) { + if isAWFNetworkIsolationEnabled(nil) { + t.Error("Expected false for nil workflow data") + } + }) + + t.Run("returns false when agent config is missing", func(t *testing.T) { + workflowData := &WorkflowData{} + if isAWFNetworkIsolationEnabled(workflowData) { + t.Error("Expected false when agent config is missing") + } + }) + + t.Run("returns false when agent is disabled", func(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Disabled: true, + NetworkIsolation: true, + }, + }, + } + if isAWFNetworkIsolationEnabled(workflowData) { + t.Error("Expected false when agent is disabled") + } + }) + + t.Run("returns true when network isolation is enabled", func(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + NetworkIsolation: true, + }, + }, + } + if !isAWFNetworkIsolationEnabled(workflowData) { + t.Error("Expected true when network isolation is enabled") + } + }) +} diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index e5480169459..20d6c55722e 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -204,6 +204,13 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract network-isolation (AWF topology egress mode) + if isolationVal, hasIsolation := agentObj["network-isolation"]; hasIsolation { + if isolationBool, ok := isolationVal.(bool); ok { + agentConfig.NetworkIsolation = isolationBool + } + } + // Extract config for SRT if configVal, hasConfig := agentObj["config"]; hasConfig { agentConfig.Config = c.extractSRTConfig(configVal) diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 414df3ea474..497e161a92a 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -39,6 +39,21 @@ func TestExtractAgentSandboxConfigPlatform(t *testing.T) { }) } +func TestExtractAgentSandboxConfigNetworkIsolation(t *testing.T) { + compiler := &Compiler{} + + t.Run("extracts sandbox.agent.network-isolation from object format", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "network-isolation": true, + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + assert.True(t, config.NetworkIsolation, "Should extract sandbox.agent.network-isolation") + }) +} + func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { compiler := &Compiler{} diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go index cc750117c13..0283485e5bd 100644 --- a/pkg/workflow/mcp_setup_generator.go +++ b/pkg/workflow/mcp_setup_generator.go @@ -594,6 +594,8 @@ func resolveMCPGatewayValues(workflowData *WorkflowData, gatewayConfig *MCPGatew if domain == "" { if workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { domain = "localhost" + } else if isAWFNetworkIsolationEnabled(workflowData) { + domain = "awmg-mcpg" } else { domain = "host.docker.internal" } @@ -640,9 +642,10 @@ func writeMCPGatewayExports(yaml *strings.Builder, opts writeMCPGatewayExportsOp yaml.WriteString(" export MCP_GATEWAY_DOMAIN=\"" + domain + "\"\n") // MCP_GATEWAY_HOST_DOMAIN is the domain used by host-side clients (e.g. Gemini CLI). // When MCP_GATEWAY_DOMAIN is host.docker.internal (only reachable from containers), - // use localhost instead; otherwise inherit the configured domain as-is. + // or when network isolation is active (gateway on bridge; host reaches it via the + // published 127.0.0.1 port), use localhost instead; otherwise inherit the domain. hostDomain := domain - if domain == "host.docker.internal" { + if domain == "host.docker.internal" || isAWFNetworkIsolationEnabled(workflowData) { hostDomain = "localhost" } yaml.WriteString(" export MCP_GATEWAY_HOST_DOMAIN=\"" + hostDomain + "\"\n") @@ -716,9 +719,19 @@ func buildMCPGatewayContainerCommand(opts buildMCPGatewayContainerCommandOptions // appendMCPGatewayBaseEnvFlags alone write ~2KB of -e flags; allocating // 2048 bytes upfront covers the common case without overcommitting. containerCmd.Grow(2048) - containerCmd.WriteString("docker run -i --rm --network host") + containerCmd.WriteString("docker run -i --rm") + if isAWFNetworkIsolationEnabled(workflowData) { + containerCmd.WriteString(" --network bridge") + // Publish the gateway port to the host so host-side clients (e.g. Gemini CLI) + // can reach the gateway at localhost:${MCP_GATEWAY_PORT}. + containerCmd.WriteString(" -p 127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}") + } else { + containerCmd.WriteString(" --network host") + } containerCmd.WriteString(" --name awmg-mcpg") - containerCmd.WriteString(" --add-host host.docker.internal:127.0.0.1") + if !isAWFNetworkIsolationEnabled(workflowData) { + containerCmd.WriteString(" --add-host host.docker.internal:127.0.0.1") + } containerCmd.WriteString(" --user ${MCP_GATEWAY_UID}:${MCP_GATEWAY_GID}") containerCmd.WriteString(" --group-add ${DOCKER_SOCK_GID}") containerCmd.WriteString(" -v ${DOCKER_SOCK_PATH}:/var/run/docker.sock") diff --git a/pkg/workflow/mcp_setup_generator_test.go b/pkg/workflow/mcp_setup_generator_test.go index 225988148fb..ad07a861995 100644 --- a/pkg/workflow/mcp_setup_generator_test.go +++ b/pkg/workflow/mcp_setup_generator_test.go @@ -561,6 +561,52 @@ tools: "Docker command should add supplementary group before mounting the Docker socket") } +func TestMCPGatewayDockerCommandUsesBridgeInNetworkIsolationMode(t *testing.T) { + frontmatter := `--- +on: workflow_dispatch +engine: copilot +sandbox: + agent: + network-isolation: true +tools: + github: + mode: remote + toolsets: [repos] +--- + +# Test Docker Socket Group +` + + compiler := NewCompiler() + + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "test.md") + + err := os.WriteFile(inputFile, []byte(frontmatter), 0644) + require.NoError(t, err, "Failed to write test input file") + + err = compiler.CompileWorkflow(inputFile) + require.NoError(t, err, "Compilation should succeed") + + outputFile := stringutil.MarkdownToLockFile(inputFile) + content, err := os.ReadFile(outputFile) + require.NoError(t, err, "Failed to read output file") + yamlStr := string(content) + + require.Contains(t, yamlStr, `docker run -i --rm --network bridge`, + "Docker command should use bridge networking in network isolation mode") + require.Contains(t, yamlStr, `-p 127.0.0.1:`, + "Docker command should publish gateway port to host in network isolation mode") + require.NotContains(t, yamlStr, `--network host`, + "Docker command should not use host networking in network isolation mode") + require.NotContains(t, yamlStr, `--add-host host.docker.internal:127.0.0.1`, + "Docker command should not inject host.docker.internal mapping in network isolation mode") + require.Contains(t, yamlStr, `export MCP_GATEWAY_DOMAIN="awmg-mcpg"`, + "MCP gateway domain should use the internal container name in network isolation mode") + require.Contains(t, yamlStr, `export MCP_GATEWAY_HOST_DOMAIN="localhost"`, + "MCP gateway host domain should be localhost in network isolation mode so host-side clients can connect") +} + // TestMultipleHTTPMCPSecretsPassedToGatewayContainer verifies that multiple HTTP MCP servers // with different secrets all get their environment variables passed to the gateway container func TestMultipleHTTPMCPSecretsPassedToGatewayContainer(t *testing.T) { diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index fa3115f4e3d..6619c72ac19 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -46,20 +46,21 @@ type SandboxConfig struct { // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { - ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) - Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) - Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version - Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) - Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. - DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. - Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) - Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation - Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command - Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step - Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") - Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") - ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) - Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") + ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) + Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) + Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version + Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) + NetworkIsolation bool `yaml:"network-isolation,omitempty"` // AWF network topology egress mode (awf --network-isolation) + Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. + DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. + Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) + Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation + Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command + Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") + Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) + Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") } // AgentAPIProxyTargetConfig configures a single LLM provider's API proxy target. diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index d98a31220db..a49a4077a3d 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -39,8 +39,34 @@ "upstreamProxy": { "type": "string", "description": "Upstream HTTP proxy URL (e.g. \"http://proxy.corp.example.com:8080\"). When set, the AWF Squid proxy forwards traffic through this proxy." + }, + "isolation": { + "type": "boolean", + "description": "Enable network topology egress mode. AWF creates an internal awf-net and routes traffic through attached sidecars instead of host-network iptables." + }, + "topologyAttach": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Container names to attach to awf-net after startup (equivalent to repeated --topology-attach flags). Requires network.isolation=true." } - } + }, + "allOf": [ + { + "if": { + "required": ["topologyAttach"] + }, + "then": { + "properties": { + "isolation": { + "const": true + } + }, + "required": ["isolation"] + } + } + ] }, "apiProxy": { "type": "object",