Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion actions/setup/sh/start_cli_proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
10 changes: 9 additions & 1 deletion actions/setup/sh/start_difc_proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}" \
Comment on lines 40 to +50

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the same commit. Added docker rm -f awmg-proxy 2>/dev/null || true before the docker run, so any leftover container from a cancelled or retried job is cleaned up before the new one starts.

--user "$(id -u):$(id -g)" \
-e GH_TOKEN \
-e GITHUB_SERVER_URL \
Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ type AWFNetworkConfig struct {
// BlockDomains is the list of explicitly blocked egress domains.
// Maps to: --block-domains <comma-separated>
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 <name> (repeatable)
TopologyAttach []string `json:"topologyAttach,omitempty"`
}

// AWFPlatformConfig is the "platform" section of the AWF config file.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
})
}
46 changes: 27 additions & 19 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>) 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:<port>) 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
Expand All @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_difc_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions pkg/workflow/firewall_default_enablement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
7 changes: 7 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading