Skip to content

Commit 3e56ea8

Browse files
Copilotlpcoxpelikhan
authored
Add compiler support for AWF --network-isolation topology mode (ARC/DinD-compatible egress) (#41088)
* Initial plan * Add AWF network-isolation compiler support Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * Fix network-isolation issues: host domain, port publishing, container cleanup Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent b6a2eef commit 3e56ea8

16 files changed

Lines changed: 374 additions & 40 deletions

actions/setup/sh/start_cli_proxy.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ if [ -n "$POLICY" ]; then
4040
POLICY_ARGS=(--policy "$POLICY")
4141
fi
4242

43-
docker run -d --name awmg-cli-proxy --network host \
43+
DOCKER_NETWORK_ARGS=(--network host)
44+
if [ "${GH_AW_NETWORK_ISOLATION:-false}" = "true" ]; then
45+
DOCKER_NETWORK_ARGS=(--network bridge -p 127.0.0.1:18443:18443)
46+
fi
47+
48+
docker run -d --name awmg-cli-proxy "${DOCKER_NETWORK_ARGS[@]}" \
4449
--user "$(id -u):$(id -g)" \
4550
-e GH_TOKEN \
4651
-e GITHUB_SERVER_URL \

actions/setup/sh/start_difc_proxy.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ mkdir -p "$PROXY_LOG_DIR" "$MCP_LOG_DIR"
3939

4040
echo "Starting DIFC proxy container: $CONTAINER_IMAGE"
4141

42-
docker run -d --name awmg-proxy --network host \
42+
# Remove any existing container to avoid name conflicts on cancelled/retried jobs.
43+
docker rm -f awmg-proxy 2>/dev/null || true
44+
45+
DOCKER_NETWORK_ARGS=(--network host)
46+
if [ "${GH_AW_NETWORK_ISOLATION:-false}" = "true" ]; then
47+
DOCKER_NETWORK_ARGS=(--network bridge -p 127.0.0.1:18443:18443)
48+
fi
49+
50+
docker run -d --name awmg-proxy "${DOCKER_NETWORK_ARGS[@]}" \
4351
--user "$(id -u):$(id -g)" \
4452
-e GH_TOKEN \
4553
-e GITHUB_SERVER_URL \

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3257,6 +3257,10 @@
32573257
"enum": ["github.com", "ghes", "ghec", "ghec-self-hosted"],
32583258
"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."
32593259
},
3260+
"network-isolation": {
3261+
"type": "boolean",
3262+
"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."
3263+
},
32603264
"command": {
32613265
"type": "string",
32623266
"x-internal": true,

pkg/workflow/awf_config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ type AWFNetworkConfig struct {
187187
// BlockDomains is the list of explicitly blocked egress domains.
188188
// Maps to: --block-domains <comma-separated>
189189
BlockDomains []string `json:"blockDomains,omitempty"`
190+
191+
// Isolation enables topology-based egress isolation mode.
192+
// Maps to: --network-isolation
193+
Isolation bool `json:"isolation,omitempty"`
194+
195+
// TopologyAttach lists container names AWF should attach to awf-net.
196+
// Maps to: --topology-attach <name> (repeatable)
197+
TopologyAttach []string `json:"topologyAttach,omitempty"`
190198
}
191199

192200
// AWFPlatformConfig is the "platform" section of the AWF config file.
@@ -361,6 +369,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
361369
}
362370
}
363371

372+
if isAWFNetworkIsolationEnabled(config.WorkflowData) {
373+
if awfConfig.Network == nil {
374+
awfConfig.Network = &AWFNetworkConfig{}
375+
}
376+
awfConfig.Network.Isolation = true
377+
awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData)
378+
awfConfigLog.Printf("Network section: isolation enabled with %d topology attachments", len(awfConfig.Network.TopologyAttach))
379+
}
380+
364381
if platformType := extractPlatformType(config.WorkflowData); platformType != "" {
365382
awfConfig.Platform = &AWFPlatformConfig{Type: platformType}
366383
awfConfigLog.Printf("Platform section: type=%s", platformType)
@@ -503,6 +520,18 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
503520
return jsonStr, nil
504521
}
505522

523+
// buildAWFTopologyAttachList returns container names that AWF should attach to
524+
// the internal awf-net network when network isolation mode is enabled.
525+
// The list always includes the MCP gateway and conditionally includes the
526+
// host-started CLI proxy sidecar when gh-proxy mode is active.
527+
func buildAWFTopologyAttachList(workflowData *WorkflowData) []string {
528+
targets := []string{"awmg-mcpg"}
529+
if isCliProxyNeeded(workflowData) {
530+
targets = append(targets, "awmg-cli-proxy")
531+
}
532+
return targets
533+
}
534+
506535
// splitDomainList splits a comma-separated domain string into a deduplicated
507536
// slice. Empty entries are ignored. The order of the original list is preserved for
508537
// non-duplicate entries; this keeps the allow-list deterministic.

pkg/workflow/awf_config_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,31 @@ func TestBuildAWFConfigJSON(t *testing.T) {
102102
assert.Contains(t, jsonStr, "ads.example.com", "should include the blocked domain")
103103
})
104104

105+
t.Run("network isolation emits isolation and topologyAttach", func(t *testing.T) {
106+
config := AWFCommandConfig{
107+
EngineName: "copilot",
108+
AllowedDomains: "github.com",
109+
WorkflowData: &WorkflowData{
110+
EngineConfig: &EngineConfig{ID: "copilot"},
111+
NetworkPermissions: &NetworkPermissions{
112+
Firewall: &FirewallConfig{Enabled: true},
113+
},
114+
SandboxConfig: &SandboxConfig{
115+
Agent: &AgentSandboxConfig{
116+
Type: SandboxTypeAWF,
117+
NetworkIsolation: true,
118+
},
119+
},
120+
},
121+
}
122+
123+
jsonStr, err := BuildAWFConfigJSON(config)
124+
require.NoError(t, err, "BuildAWFConfigJSON should not return an error")
125+
126+
assert.Contains(t, jsonStr, `"isolation":true`, "should enable network isolation")
127+
assert.Contains(t, jsonStr, `"topologyAttach":["awmg-mcpg"]`, "should attach MCP gateway container to awf-net")
128+
})
129+
105130
t.Run("openai API target is included in apiProxy targets", func(t *testing.T) {
106131
config := AWFCommandConfig{
107132
EngineName: "codex",
@@ -1559,3 +1584,38 @@ func TestBuildAWFCommand_WritesAgentCLIStartTimestamp(t *testing.T) {
15591584
})
15601585
}
15611586
}
1587+
1588+
func TestBuildAWFTopologyAttachList(t *testing.T) {
1589+
t.Run("includes only MCP gateway when cli proxy is not needed", func(t *testing.T) {
1590+
workflowData := &WorkflowData{
1591+
Tools: map[string]any{
1592+
"github": map[string]any{
1593+
"toolsets": []string{"repos"},
1594+
},
1595+
},
1596+
NetworkPermissions: &NetworkPermissions{
1597+
Firewall: &FirewallConfig{Enabled: true},
1598+
},
1599+
}
1600+
1601+
targets := buildAWFTopologyAttachList(workflowData)
1602+
assert.Equal(t, []string{"awmg-mcpg"}, targets)
1603+
})
1604+
1605+
t.Run("includes CLI proxy when gh-proxy mode is enabled", func(t *testing.T) {
1606+
workflowData := &WorkflowData{
1607+
Tools: map[string]any{
1608+
"github": map[string]any{
1609+
"mode": "gh-proxy",
1610+
"toolsets": []string{"repos"},
1611+
},
1612+
},
1613+
NetworkPermissions: &NetworkPermissions{
1614+
Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"},
1615+
},
1616+
}
1617+
1618+
targets := buildAWFTopologyAttachList(workflowData)
1619+
assert.Equal(t, []string{"awmg-mcpg", "awmg-cli-proxy"}, targets)
1620+
})
1621+
}

pkg/workflow/awf_helpers.go

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -611,25 +611,29 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
611611
awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled")
612612
}
613613

614-
// Always add --enable-host-access: needed for the API proxy sidecar
615-
// (to reach host.docker.internal:<port>) and for MCP gateway communication
616-
awfArgs = append(awfArgs, "--enable-host-access")
617-
awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway")
618-
619-
// AWF's --enable-host-access defaults to ports 80,443. The MCP gateway now
620-
// listens on port 8080 (non-privileged), so we must explicitly allow it
621-
// when AWF supports --allow-host-ports.
622-
if awfSupportsAllowHostPorts(firewallConfig) {
623-
mcpGatewayPort := int(DefaultMCPGatewayPort)
624-
if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil &&
625-
config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 {
626-
mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port
627-
}
628-
hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort)
629-
awfArgs = append(awfArgs, "--allow-host-ports", hostPorts)
630-
awfHelpersLog.Printf("Added --allow-host-ports %s for MCP gateway access", hostPorts)
614+
if isAWFNetworkIsolationEnabled(config.WorkflowData) {
615+
awfHelpersLog.Print("Skipping host-access flags: sandbox.agent.network-isolation is enabled")
631616
} else {
632-
awfHelpersLog.Printf("Skipping --allow-host-ports: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAllowHostPortsMinVersion)
617+
// Always add --enable-host-access: needed for the API proxy sidecar
618+
// (to reach host.docker.internal:<port>) and for MCP gateway communication
619+
awfArgs = append(awfArgs, "--enable-host-access")
620+
awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway")
621+
622+
// AWF's --enable-host-access defaults to ports 80,443. The MCP gateway now
623+
// listens on port 8080 (non-privileged), so we must explicitly allow it
624+
// when AWF supports --allow-host-ports.
625+
if awfSupportsAllowHostPorts(firewallConfig) {
626+
mcpGatewayPort := int(DefaultMCPGatewayPort)
627+
if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil &&
628+
config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 {
629+
mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port
630+
}
631+
hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort)
632+
awfArgs = append(awfArgs, "--allow-host-ports", hostPorts)
633+
awfHelpersLog.Printf("Added --allow-host-ports %s for MCP gateway access", hostPorts)
634+
} else {
635+
awfHelpersLog.Printf("Skipping --allow-host-ports: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAllowHostPortsMinVersion)
636+
}
633637
}
634638

635639
// Skip pulling images since they are pre-downloaded
@@ -641,7 +645,11 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
641645
// (firewall v0.25.17+).
642646
if isGitHubCLIModeEnabled(config.WorkflowData) {
643647
if awfSupportsCliProxy(firewallConfig) {
644-
awfArgs = append(awfArgs, "--difc-proxy-host", "host.docker.internal:18443")
648+
difcProxyHost := "host.docker.internal:18443"
649+
if isAWFNetworkIsolationEnabled(config.WorkflowData) {
650+
difcProxyHost = "awmg-cli-proxy:18443"
651+
}
652+
awfArgs = append(awfArgs, "--difc-proxy-host", difcProxyHost)
645653
awfArgs = append(awfArgs, "--difc-proxy-ca-cert", constants.TmpDIFCProxyTLSCACert)
646654
awfHelpersLog.Print("Added --difc-proxy-host and --difc-proxy-ca-cert for CLI proxy sidecar")
647655
} else {

pkg/workflow/awf_helpers_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,32 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) {
638638

639639
assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports for AWF versions below minimum support")
640640
})
641+
642+
t.Run("skips host-access flags when network isolation is enabled", func(t *testing.T) {
643+
config := AWFCommandConfig{
644+
EngineName: "copilot",
645+
WorkflowData: &WorkflowData{
646+
Name: "test-workflow",
647+
EngineConfig: &EngineConfig{ID: "copilot"},
648+
NetworkPermissions: &NetworkPermissions{
649+
Firewall: &FirewallConfig{Enabled: true},
650+
},
651+
SandboxConfig: &SandboxConfig{
652+
Agent: &AgentSandboxConfig{
653+
Type: SandboxTypeAWF,
654+
NetworkIsolation: true,
655+
},
656+
},
657+
},
658+
AllowedDomains: "github.com",
659+
}
660+
661+
args := BuildAWFArgs(config)
662+
argsStr := strings.Join(args, " ")
663+
664+
assert.NotContains(t, argsStr, "--enable-host-access", "Should skip --enable-host-access in network isolation mode")
665+
assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports in network isolation mode")
666+
})
641667
}
642668

643669
// TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs
@@ -1123,6 +1149,36 @@ func TestBuildAWFArgsCliProxy(t *testing.T) {
11231149
assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy")
11241150
})
11251151

1152+
t.Run("uses internal cli proxy host when network isolation is enabled", func(t *testing.T) {
1153+
config := AWFCommandConfig{
1154+
EngineName: "copilot",
1155+
WorkflowData: &WorkflowData{
1156+
Name: "test-workflow",
1157+
EngineConfig: &EngineConfig{
1158+
ID: "copilot",
1159+
},
1160+
NetworkPermissions: &NetworkPermissions{
1161+
Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"},
1162+
},
1163+
SandboxConfig: &SandboxConfig{
1164+
Agent: &AgentSandboxConfig{
1165+
Type: SandboxTypeAWF,
1166+
NetworkIsolation: true,
1167+
},
1168+
},
1169+
Features: map[string]any{"cli-proxy": true},
1170+
},
1171+
AllowedDomains: "github.com",
1172+
}
1173+
1174+
args := BuildAWFArgs(config)
1175+
argsStr := strings.Join(args, " ")
1176+
1177+
assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled")
1178+
assert.Contains(t, argsStr, "awmg-cli-proxy:18443", "Should use internal awf-net CLI proxy address in isolation mode")
1179+
assert.NotContains(t, argsStr, "host.docker.internal:18443", "Should not use host.docker.internal in isolation mode")
1180+
})
1181+
11261182
t.Run("does not include cli-proxy flags for copilot by default", func(t *testing.T) {
11271183
config := AWFCommandConfig{
11281184
EngineName: "copilot",

pkg/workflow/compiler_difc_proxy.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ func (c *Compiler) buildStartDIFCProxyStepYAML(data *WorkflowData) string {
249249
sb.WriteString(" env:\n")
250250
fmt.Fprintf(&sb, " GH_TOKEN: %s\n", effectiveToken)
251251
sb.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n")
252+
if isAWFNetworkIsolationEnabled(data) {
253+
sb.WriteString(" GH_AW_NETWORK_ISOLATION: 'true'\n")
254+
}
252255
// Store policy and image in env vars to avoid shell-quoting issues with
253256
// inline JSON arguments and to keep the run: command clean.
254257
fmt.Fprintf(&sb, " DIFC_PROXY_POLICY: '%s'\n", policyJSON)
@@ -519,6 +522,9 @@ func (c *Compiler) buildStartCliProxyStepYAML(data *WorkflowData) string {
519522
sb.WriteString(" env:\n")
520523
fmt.Fprintf(&sb, " GH_TOKEN: %s\n", effectiveToken)
521524
sb.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n")
525+
if isAWFNetworkIsolationEnabled(data) {
526+
sb.WriteString(" GH_AW_NETWORK_ISOLATION: 'true'\n")
527+
}
522528
fmt.Fprintf(&sb, " CLI_PROXY_POLICY: '%s'\n", policyJSON)
523529
fmt.Fprintf(&sb, " CLI_PROXY_IMAGE: '%s'\n", containerImage)
524530
sb.WriteString(" run: |\n")

pkg/workflow/firewall.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ func getAgentConfig(workflowData *WorkflowData) *AgentSandboxConfig {
115115
return workflowData.SandboxConfig.Agent
116116
}
117117

118+
func isAWFNetworkIsolationEnabled(workflowData *WorkflowData) bool {
119+
agentConfig := getAgentConfig(workflowData)
120+
if agentConfig == nil || agentConfig.Disabled {
121+
return false
122+
}
123+
return agentConfig.NetworkIsolation
124+
}
125+
118126
// enableFirewallByDefaultForCopilot enables firewall by default for copilot and codex engines
119127
// when network restrictions are present but no explicit firewall configuration exists
120128
// and no SRT sandbox is configured (SRT and AWF are mutually exclusive)

pkg/workflow/firewall_default_enablement_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,45 @@ func TestStrictModeFirewallValidation(t *testing.T) {
537537
}
538538
})
539539
}
540+
541+
func TestIsAWFNetworkIsolationEnabled(t *testing.T) {
542+
t.Run("returns false when workflow data is nil", func(t *testing.T) {
543+
if isAWFNetworkIsolationEnabled(nil) {
544+
t.Error("Expected false for nil workflow data")
545+
}
546+
})
547+
548+
t.Run("returns false when agent config is missing", func(t *testing.T) {
549+
workflowData := &WorkflowData{}
550+
if isAWFNetworkIsolationEnabled(workflowData) {
551+
t.Error("Expected false when agent config is missing")
552+
}
553+
})
554+
555+
t.Run("returns false when agent is disabled", func(t *testing.T) {
556+
workflowData := &WorkflowData{
557+
SandboxConfig: &SandboxConfig{
558+
Agent: &AgentSandboxConfig{
559+
Disabled: true,
560+
NetworkIsolation: true,
561+
},
562+
},
563+
}
564+
if isAWFNetworkIsolationEnabled(workflowData) {
565+
t.Error("Expected false when agent is disabled")
566+
}
567+
})
568+
569+
t.Run("returns true when network isolation is enabled", func(t *testing.T) {
570+
workflowData := &WorkflowData{
571+
SandboxConfig: &SandboxConfig{
572+
Agent: &AgentSandboxConfig{
573+
NetworkIsolation: true,
574+
},
575+
},
576+
}
577+
if !isAWFNetworkIsolationEnabled(workflowData) {
578+
t.Error("Expected true when network isolation is enabled")
579+
}
580+
})
581+
}

0 commit comments

Comments
 (0)