From 36aa1a5b60ba470457b152b73702dcd9f6de74a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:07:01 +0000 Subject: [PATCH 1/4] Initial plan From 9bab7a89bcf26f9b38e57c449d387672d488247b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:31:03 +0000 Subject: [PATCH 2/4] Allow sandbox agents to reach service ports Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/smoke-service-ports.lock.yml | 2 +- docs/public/editor/autocomplete-data.json | 5 + docs/src/content/docs/guides/upgrading.md | 2 + .../docs/reference/frontmatter-full.md | 6 + docs/src/content/docs/reference/sandbox.md | 14 ++ pkg/parser/schemas/main_workflow_schema.json | 10 ++ pkg/workflow/awf_command_builder.go | 70 +++++++-- pkg/workflow/awf_command_builder_test.go | 145 +++++++++++++++++- .../frontmatter_extraction_security.go | 21 +++ .../frontmatter_extraction_security_test.go | 12 ++ pkg/workflow/sandbox.go | 1 + pkg/workflow/sandbox_validation.go | 15 ++ pkg/workflow/sandbox_validation_test.go | 28 ++++ pkg/workflow/service_ports.go | 85 ++++++++++ 14 files changed, 400 insertions(+), 16 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index 01e4db70656..6c93a5d1a46 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -913,7 +913,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --legacy-security --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --legacy-security --enable-host-access --allow-host-ports 80,443,6379,8080 --skip-pull \ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index e70899cbaba..bd061374e30 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -843,6 +843,11 @@ "desc": "Opt into legacy security mode.", "enum": ["enable"], "leaf": true + }, + "allow-host-ports": { + "type": "array", + "desc": "Additional host TCP ports the agent may connect to.", + "leaf": true } } }, diff --git a/docs/src/content/docs/guides/upgrading.md b/docs/src/content/docs/guides/upgrading.md index 6f28bbbd9b8..d96df442e4a 100644 --- a/docs/src/content/docs/guides/upgrading.md +++ b/docs/src/content/docs/guides/upgrading.md @@ -54,6 +54,8 @@ This updates `.github/skills/agentic-workflows/SKILL.md` to the latest template, Run `git diff .github/workflows/` to verify the changes. Typical migrations include `sandbox: false` → `sandbox.agent: false`, `app:` → `github-app:`, `safe-inputs:` → `mcp-scripts:`, `daily at` → `daily around`, and removal of deprecated `network.firewall` and `mcp-scripts.mode` fields. +Workflows that use GitHub Actions `services:` with published ports now compile sandbox allowlists for those host TCP ports automatically. If you previously moved service-dependent work outside the agent or enabled `legacy-security` only to reach a service, recompile the workflow and review the generated `--allow-host-ports` value. + ## Step 4: Commit and Push Stage and commit your changes: diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 301ff785bed..40893caa52d 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2227,6 +2227,12 @@ sandbox: # (optional) legacy-security: "enable" + # Additional host TCP ports the agent may connect to. Ports published by + # `services:` are allowed automatically; use this only for ports not declared + # there. + # (optional) + allow-host-ports: [] + # Legacy custom Sandbox Runtime configuration (use agent.config instead). Note: # Network configuration is controlled by the top-level 'network' field, not here. # (optional) diff --git a/docs/src/content/docs/reference/sandbox.md b/docs/src/content/docs/reference/sandbox.md index 11f53db488d..4b2070e9646 100644 --- a/docs/src/content/docs/reference/sandbox.md +++ b/docs/src/content/docs/reference/sandbox.md @@ -118,6 +118,20 @@ All host binaries are available without explicit mounts: system utilities, `gh`, > [!WARNING] > Docker socket is hidden for security. Agents cannot spawn containers. +#### Host Service Ports (`services:`) + +When a workflow declares GitHub Actions `services:` with published ports, gh-aw automatically allows the agent sandbox to connect to those host TCP ports. For example, a service port mapping such as `5432:5432` makes host port `5432` reachable from the agent without enabling legacy security mode. + +For host daemons that are not declared in `services:`, add an explicit allowlist: + +```yaml wrap +sandbox: + agent: + allow-host-ports: [5432, 6379] +``` + +Use `allow-host-ports` only for ports that cannot be represented by `services:`. The compiler rejects values outside the TCP port range `1` through `65535`. + #### Environment Variables AWF passes all environment variables via `--env-all`. The host `PATH` is captured as `AWF_HOST_PATH` and restored inside the container, preserving setup action tool paths. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 24aeeadc33c..ef9feda7711 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3600,6 +3600,16 @@ "type": "string", "enum": ["enable"], "description": "Opt into legacy security mode. When set to 'enable', AWF runs with sudo and --enable-host-access for backward compatibility. The default (omitted) uses strict security mode where AWF runs rootless without host-access flags." + }, + "allow-host-ports": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "uniqueItems": true, + "description": "Additional host TCP ports the agent may connect to. Ports published by `services:` are allowed automatically; use this only for ports not declared there." } }, "additionalProperties": false diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index 4cee6553adc..f7b9ad95afe 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -4,10 +4,12 @@ package workflow import ( "fmt" + "os" "sort" "strconv" "strings" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/workflow/compilerenv" ) @@ -506,7 +508,7 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled") } - // Legacy security mode: emit --legacy-security, --enable-host-access, and --allow-host-ports + // Legacy security mode: emit --legacy-security and --enable-host-access. isLegacy := agentConfig != nil && agentConfig.LegacySecurity if isLegacy { if awfSupportsLegacySecurity(firewallConfig) { @@ -520,19 +522,21 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfArgs = append(awfArgs, "--enable-host-access") awfHelpersLog.Print("Added --enable-host-access for legacy security mode") + } else { + awfHelpersLog.Print("Strict security: skipping host-access flag (default)") + } + hostPorts := collectAllowedHostPorts(config.WorkflowData, agentConfig, isLegacy) + if len(hostPorts) > 0 { 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 legacy security mode", hostPorts) + hostPortsValue := joinPorts(hostPorts) + awfArgs = append(awfArgs, "--allow-host-ports", hostPortsValue) + awfHelpersLog.Printf("Added --allow-host-ports %s", hostPortsValue) + } else { + warning := fmt.Sprintf("sandbox host ports require AWF %s or newer; skipping --allow-host-ports for AWF version %q", constants.AWFAllowHostPortsMinVersion, getAWFImageTag(firewallConfig)) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) + awfHelpersLog.Printf("Warning: %s", warning) } - } else { - awfHelpersLog.Print("Strict security: skipping host-access flags (default)") } // Skip pulling images since they are pre-downloaded @@ -603,6 +607,50 @@ func BuildAWFArgs(config AWFCommandConfig) []string { return awfArgs } +func collectAllowedHostPorts(workflowData *WorkflowData, agentConfig *AgentSandboxConfig, includeDefaultPorts bool) []int { + ports := map[int]struct{}{} + for _, port := range collectServiceHostPorts(workflowData) { + ports[port] = struct{}{} + } + if agentConfig != nil { + for _, port := range agentConfig.AllowHostPorts { + if port >= minPort && port <= maxPort { + ports[port] = struct{}{} + } + } + } + if includeDefaultPorts || len(ports) > 0 { + ports[80] = struct{}{} + ports[443] = struct{}{} + ports[getMCPGatewayPort(workflowData)] = struct{}{} + } + if len(ports) == 0 { + return nil + } + result := make([]int, 0, len(ports)) + for port := range ports { + result = append(result, port) + } + sort.Ints(result) + return result +} + +func getMCPGatewayPort(workflowData *WorkflowData) int { + if workflowData != nil && workflowData.SandboxConfig != nil && + workflowData.SandboxConfig.MCP != nil && workflowData.SandboxConfig.MCP.Port > 0 { + return workflowData.SandboxConfig.MCP.Port + } + return int(DefaultMCPGatewayPort) +} + +func joinPorts(ports []int) string { + parts := make([]string, len(ports)) + for i, port := range ports { + parts[i] = strconv.Itoa(port) + } + return strings.Join(parts, ",") +} + // GetAWFCommandPrefix determines the AWF command to use (custom or standard). // This extracts the common pattern for determining AWF command from agent config. // diff --git a/pkg/workflow/awf_command_builder_test.go b/pkg/workflow/awf_command_builder_test.go index 76098a935d2..9659decd331 100644 --- a/pkg/workflow/awf_command_builder_test.go +++ b/pkg/workflow/awf_command_builder_test.go @@ -90,7 +90,7 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { argsStr := strings.Join(args, " ") assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") - assert.Contains(t, argsStr, "80,443,8080", "Should allow default gateway port 8080 alongside 80 and 443") + assert.Equal(t, "80,443,8080", argValue(args, "--allow-host-ports"), "Should allow default gateway port 8080 alongside 80 and 443") }) t.Run("uses custom MCP gateway port from sandbox config", func(t *testing.T) { @@ -114,7 +114,7 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { argsStr := strings.Join(args, " ") assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") - assert.Contains(t, argsStr, "80,443,9090", "Should use custom gateway port from sandbox config") + assert.Equal(t, "80,443,9090", argValue(args, "--allow-host-ports"), "Should use custom gateway port from sandbox config") assert.NotContains(t, argsStr, "8080", "Should not include default port when custom port is set") }) @@ -138,7 +138,71 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode (default) should not emit --enable-host-access") }) - t.Run("skips --allow-host-ports when AWF version is too old", func(t *testing.T) { + t.Run("emits service host ports in strict mode without host access", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Services: `services: + postgres: + image: postgres:18 + ports: + - 5432:5432 +`, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--allow-host-ports", "Services should emit --allow-host-ports in strict mode") + assert.Equal(t, "80,443,5432,8080", argValue(args, "--allow-host-ports"), "Should include defaults and the declared service host port") + assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode should not imply broad host access") + }) + + t.Run("merges explicit and service host ports sorted and deduped", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Services: `services: + postgres: + image: postgres:18 + ports: + - 5432:5432 + redis: + image: redis:7 + ports: + - 6379:6379 +`, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + AllowHostPorts: []int{9200, 5432}, + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + + assert.Equal(t, "80,443,5432,6379,8080,9200", argValue(args, "--allow-host-ports"), "Should sort and dedupe default, service, and explicit host ports") + }) + + t.Run("skips --allow-host-ports and warns when AWF version is too old", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", WorkflowData: &WorkflowData{ @@ -150,14 +214,24 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { Version: "v0.25.23", }, }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + AllowHostPorts: []int{9200}, + }, + }, }, AllowedDomains: "github.com", } - args := BuildAWFArgs(config) + var args []string + stderr := captureStderr(func() { + args = BuildAWFArgs(config) + }) argsStr := strings.Join(args, " ") assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports for AWF versions below minimum support") + assert.Contains(t, stderr, string(constants.AWFAllowHostPortsMinVersion), "Warning should name the minimum AWF version") }) t.Run("skips host-access flags when network isolation is enabled", func(t *testing.T) { @@ -185,6 +259,60 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { 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") }) + + t.Run("legacy security keeps host access and includes service ports", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Services: `services: + postgres: + image: postgres:18 + ports: + - 5432:5432 +`, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + LegacySecurity: true, + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--enable-host-access", "Legacy mode should still emit broad host access") + assert.Equal(t, "80,443,5432,8080", argValue(args, "--allow-host-ports"), "Legacy mode should merge default and service ports") + }) +} + +func TestCollectServiceHostPorts(t *testing.T) { + tests := []struct { + name string + portSpec string + want []int + }{ + {name: "host container mapping", portSpec: "5432:5432", want: []int{5432}}, + {name: "bare port", portSpec: "5432", want: []int{5432}}, + {name: "ip host container mapping", portSpec: "127.0.0.1:5432:5432", want: []int{5432}}, + {name: "udp suffix ignored", portSpec: "5432:5432/udp", want: []int{5432}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + Services: "services:\n db:\n image: postgres:18\n ports:\n - " + tt.portSpec + "\n", + } + + assert.Equal(t, tt.want, collectServiceHostPorts(workflowData)) + }) + } } // TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs @@ -674,3 +802,12 @@ func TestBuildAWFCommand_ServicePortsRequireLegacy(t *testing.T) { assert.NotContains(t, cmd, "--allow-host-service-ports", "Should NOT emit --allow-host-service-ports in strict mode") }) } + +func argValue(args []string, flag string) string { + for i, arg := range args { + if arg == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 6747b154435..d303c84599e 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -298,6 +298,27 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract allow-host-ports (additional host TCP ports for the AWF sandbox) + if portsVal, hasPorts := agentObj["allow-host-ports"]; hasPorts { + if portsSlice, ok := portsVal.([]any); ok { + for _, portVal := range portsSlice { + switch v := portVal.(type) { + case int: + agentConfig.AllowHostPorts = append(agentConfig.AllowHostPorts, v) + case int64: + agentConfig.AllowHostPorts = append(agentConfig.AllowHostPorts, int(v)) + case uint64: + agentConfig.AllowHostPorts = append(agentConfig.AllowHostPorts, int(v)) + case float64: + if float64(int(v)) == v { + agentConfig.AllowHostPorts = append(agentConfig.AllowHostPorts, int(v)) + } + } + } + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.allow-host-ports: %v", agentConfig.AllowHostPorts) + } + } + // Extract model-fallback (AWF API proxy model fallback enable/disable flag) if mfVal, hasMF := agentObj["model-fallback"]; hasMF { switch v := mfVal.(type) { diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index eabe91abc11..1efde9dd947 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -139,6 +139,18 @@ func TestExtractAgentSandboxConfigLegacySecurity(t *testing.T) { }) } +func TestExtractAgentSandboxConfigAllowHostPorts(t *testing.T) { + compiler := &Compiler{} + + config := compiler.extractAgentSandboxConfig(map[string]any{ + "id": "awf", + "allow-host-ports": []any{5432, 9200}, + }) + + require.NotNil(t, config, "Should extract agent sandbox config") + assert.Equal(t, []int{5432, 9200}, config.AllowHostPorts) +} + func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { compiler := &Compiler{} diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index defdc9d1934..81e11ea2111 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -70,6 +70,7 @@ type AgentSandboxConfig struct { NetworkIsolation bool `yaml:"sudo,omitempty"` // Internal: true = isolation mode (AWF --network-isolation). Frontmatter sudo: false (or omitted) maps to NetworkIsolation=true; sudo: true maps to NetworkIsolation=false. SudoExplicitlyEnabled bool `yaml:"-"` // True when sudo: true was explicitly set in frontmatter. Used to emit an error (strict) or warning (non-strict) at compile time. LegacySecurity bool `yaml:"-"` // True when legacy-security: enable was set in frontmatter. Enables sudo, host-access, and iptables-based mode. + AllowHostPorts []int `yaml:"-"` // Additional host TCP ports the agent may connect to. 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) diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index cc1deba597c..23c94caa622 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -118,6 +118,12 @@ func validateSandboxConfig(workflowData *WorkflowData) error { } } + if agentConfig != nil && len(agentConfig.AllowHostPorts) > 0 { + if err := validateAllowHostPorts(agentConfig.AllowHostPorts); err != nil { + return err + } + } + // Validate gVisor runtime compatibility if agentConfig != nil && agentConfig.Runtime == AgentRuntimeGVisor { // gVisor is incompatible with ARC/DinD topology: the runner has no access to the @@ -490,6 +496,15 @@ func validateAgentMemoryLimit(memory string) error { return nil } +func validateAllowHostPorts(ports []int) error { + for _, port := range ports { + if port < minPort || port > maxPort { + return fmt.Errorf("invalid allow-host-ports value: %d. Expected a TCP port between 1 and 65535. Example: allow-host-ports: [5432]", port) + } + } + return nil +} + func getSandboxDisableJustification(workflowData *WorkflowData) (string, error) { if workflowData == nil || workflowData.Features == nil { return "", errors.New("dangerously-disable-sandbox-agent feature is missing") diff --git a/pkg/workflow/sandbox_validation_test.go b/pkg/workflow/sandbox_validation_test.go index 0f01402e60e..119ccb69043 100644 --- a/pkg/workflow/sandbox_validation_test.go +++ b/pkg/workflow/sandbox_validation_test.go @@ -340,3 +340,31 @@ func TestValidateSandboxConfigMemory(t *testing.T) { assert.NoError(t, err, "absent memory should pass validation") }) } + +func TestValidateSandboxConfigAllowHostPorts(t *testing.T) { + t.Run("valid allow-host-ports passes validation", func(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{AllowHostPorts: []int{5432, 9200}}, + }, + } + + err := validateSandboxConfig(workflowData) + assert.NoError(t, err, "valid allow-host-ports should pass validation") + }) + + t.Run("out-of-range allow-host-ports fails validation", func(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{AllowHostPorts: []int{0}}, + }, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "out-of-range allow-host-ports should fail validation") + assert.Contains(t, err.Error(), "invalid allow-host-ports value: 0") + assert.Contains(t, err.Error(), "Example: allow-host-ports: [5432]") + }) +} diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index d7120341b6f..efdda3c5474 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -11,6 +11,7 @@ package workflow import ( "fmt" + "sort" "strconv" "strings" @@ -135,6 +136,90 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { return result, warnings } +func collectServiceHostPorts(workflowData *WorkflowData) []int { + if workflowData == nil || workflowData.Services == "" { + return nil + } + + var wrapper servicesYAMLWrapper + if err := yaml.Unmarshal([]byte(workflowData.Services), &wrapper); err != nil { + servicePortsLog.Printf("Failed to parse services YAML for host ports: %v", err) + return nil + } + if wrapper.Services == nil { + return nil + } + + seen := map[int]struct{}{} + serviceIDs := sliceutil.SortedKeys(wrapper.Services) + for _, serviceID := range serviceIDs { + svc := wrapper.Services[serviceID] + if svc == nil || svc.Ports == nil { + continue + } + portsList, ok := svc.Ports.([]any) + if !ok { + continue + } + for _, portSpec := range portsList { + if port, ok := parseServiceHostPort(portSpec); ok { + seen[port] = struct{}{} + } + } + } + + ports := make([]int, 0, len(seen)) + for port := range seen { + ports = append(ports, port) + } + sort.Ints(ports) + return ports +} + +func parseServiceHostPort(spec any) (int, bool) { + switch v := spec.(type) { + case int: + return validServiceHostPort(v) + case int64: + return validServiceHostPort(int(v)) + case uint64: + return validServiceHostPort(int(v)) + case float64: + p := int(v) + if float64(p) != v { + return 0, false + } + return validServiceHostPort(p) + case string: + portStr := strings.TrimSpace(v) + if portStr == "" { + return 0, false + } + if idx := strings.LastIndex(portStr, "/"); idx != -1 { + portStr = portStr[:idx] + } + parts := strings.Split(portStr, ":") + hostPart := parts[0] + if len(parts) >= 3 { + hostPart = parts[len(parts)-2] + } + port, err := strconv.Atoi(hostPart) + if err != nil { + return 0, false + } + return validServiceHostPort(port) + default: + return 0, false + } +} + +func validServiceHostPort(port int) (int, bool) { + if port < minPort || port > maxPort { + return 0, false + } + return port, true +} + // parsePortSpec parses a single port specification and returns the container port(s). // Supports formats: // - "5432:5432" (host:container) From c6608377f96fbd8410e488e79d54a6b96efd2032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:25:11 +0000 Subject: [PATCH 3/4] Fix strict-mode host-port allowlist to match AWF's actual security model Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/public/editor/autocomplete-data.json | 2 +- docs/src/content/docs/guides/upgrading.md | 2 +- .../docs/reference/frontmatter-full.md | 7 +- docs/src/content/docs/reference/sandbox.md | 21 +++- pkg/parser/schemas/main_workflow_schema.json | 2 +- pkg/workflow/awf_command_builder.go | 58 +++++---- pkg/workflow/awf_command_builder_test.go | 77 ++---------- pkg/workflow/sandbox_validation.go | 3 + pkg/workflow/sandbox_validation_test.go | 17 ++- pkg/workflow/service_ports.go | 117 +++++------------- 10 files changed, 124 insertions(+), 182 deletions(-) diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index bd061374e30..7ab59c9529f 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -846,7 +846,7 @@ }, "allow-host-ports": { "type": "array", - "desc": "Additional host TCP ports the agent may connect to.", + "desc": "Additional host TCP ports the agent may connect to when legacy-security is enabled.", "leaf": true } } diff --git a/docs/src/content/docs/guides/upgrading.md b/docs/src/content/docs/guides/upgrading.md index d96df442e4a..6dbea84cc8e 100644 --- a/docs/src/content/docs/guides/upgrading.md +++ b/docs/src/content/docs/guides/upgrading.md @@ -54,7 +54,7 @@ This updates `.github/skills/agentic-workflows/SKILL.md` to the latest template, Run `git diff .github/workflows/` to verify the changes. Typical migrations include `sandbox: false` → `sandbox.agent: false`, `app:` → `github-app:`, `safe-inputs:` → `mcp-scripts:`, `daily at` → `daily around`, and removal of deprecated `network.firewall` and `mcp-scripts.mode` fields. -Workflows that use GitHub Actions `services:` with published ports now compile sandbox allowlists for those host TCP ports automatically. If you previously moved service-dependent work outside the agent or enabled `legacy-security` only to reach a service, recompile the workflow and review the generated `--allow-host-ports` value. +Workflows that use GitHub Actions `services:` with published ports remain reachable from the agent sandbox only when `sandbox.agent.legacy-security: enable` is set; recompiling regenerates the `--allow-host-service-ports` value used to reach those services. ## Step 4: Commit and Push diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 40893caa52d..e913ab1c48c 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2227,9 +2227,10 @@ sandbox: # (optional) legacy-security: "enable" - # Additional host TCP ports the agent may connect to. Ports published by - # `services:` are allowed automatically; use this only for ports not declared - # there. + # Additional host TCP ports the agent may connect to when legacy-security is + # enabled. Ports published by `services:` are reached via + # --allow-host-service-ports instead; use this only for host daemons not + # declared there. # (optional) allow-host-ports: [] diff --git a/docs/src/content/docs/reference/sandbox.md b/docs/src/content/docs/reference/sandbox.md index 4b2070e9646..6e7ad43b787 100644 --- a/docs/src/content/docs/reference/sandbox.md +++ b/docs/src/content/docs/reference/sandbox.md @@ -120,17 +120,30 @@ All host binaries are available without explicit mounts: system utilities, `gh`, #### Host Service Ports (`services:`) -When a workflow declares GitHub Actions `services:` with published ports, gh-aw automatically allows the agent sandbox to connect to those host TCP ports. For example, a service port mapping such as `5432:5432` makes host port `5432` reachable from the agent without enabling legacy security mode. +The AWF sandbox reaches GitHub Actions `services:` containers through `--allow-host-service-ports`, which resolves each service's actual (possibly dynamically assigned) host port at runtime. This mechanism, and the explicit `allow-host-ports` escape hatch below, both require `sandbox.agent.legacy-security: enable`: AWF's strict (default) security mode does not provide a route to host services, even when host-access flags are combined. -For host daemons that are not declared in `services:`, add an explicit allowlist: +```yaml wrap +sandbox: + agent: + legacy-security: enable + +services: + postgres: + image: postgres:18 + ports: + - 5432:5432 +``` + +For host daemons that are not declared in `services:`, add an explicit allowlist (also legacy-security only): ```yaml wrap sandbox: agent: - allow-host-ports: [5432, 6379] + legacy-security: enable + allow-host-ports: [9000] ``` -Use `allow-host-ports` only for ports that cannot be represented by `services:`. The compiler rejects values outside the TCP port range `1` through `65535`. +Use `allow-host-ports` only for ports that cannot be represented by `services:`. The compiler rejects values outside the TCP port range `1` through `65535`, and rejects ports AWF always blocks as dangerous (e.g. `22`, `3306`, `5432`, `6379`, `9200`) — reach those through `services:` instead. #### Environment Variables diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ef9feda7711..4bfa6b05ed0 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3609,7 +3609,7 @@ "maximum": 65535 }, "uniqueItems": true, - "description": "Additional host TCP ports the agent may connect to. Ports published by `services:` are allowed automatically; use this only for ports not declared there." + "description": "Additional host TCP ports the agent may connect to when legacy-security is enabled. Ports published by `services:` are reached via --allow-host-service-ports instead; use this only for host daemons not declared there." } }, "additionalProperties": false diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index f7b9ad95afe..ec94b64551c 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -522,18 +522,29 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfArgs = append(awfArgs, "--enable-host-access") awfHelpersLog.Print("Added --enable-host-access for legacy security mode") + + // --allow-host-ports requires --enable-host-access, so this is only ever + // emitted in legacy-security mode. AWF's strict security mode (the default) + // does not provide a route to host services even when --allow-host-ports is + // combined with --enable-host-access, so emitting it there would be both + // invalid (strict mode strips --enable-host-access on incompatible runtimes) + // and misleading (it would not make services reachable). + hostPorts := collectAllowedHostPorts(config.WorkflowData, agentConfig) + if len(hostPorts) > 0 { + if awfSupportsAllowHostPorts(firewallConfig) { + hostPortsValue := joinPorts(hostPorts) + awfArgs = append(awfArgs, "--allow-host-ports", hostPortsValue) + awfHelpersLog.Printf("Added --allow-host-ports %s", hostPortsValue) + } else { + warning := fmt.Sprintf("sandbox host ports require AWF %s or newer; skipping --allow-host-ports for AWF version %q", constants.AWFAllowHostPortsMinVersion, getAWFImageTag(firewallConfig)) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) + awfHelpersLog.Printf("Warning: %s", warning) + } + } } else { awfHelpersLog.Print("Strict security: skipping host-access flag (default)") - } - - hostPorts := collectAllowedHostPorts(config.WorkflowData, agentConfig, isLegacy) - if len(hostPorts) > 0 { - if awfSupportsAllowHostPorts(firewallConfig) { - hostPortsValue := joinPorts(hostPorts) - awfArgs = append(awfArgs, "--allow-host-ports", hostPortsValue) - awfHelpersLog.Printf("Added --allow-host-ports %s", hostPortsValue) - } else { - warning := fmt.Sprintf("sandbox host ports require AWF %s or newer; skipping --allow-host-ports for AWF version %q", constants.AWFAllowHostPortsMinVersion, getAWFImageTag(firewallConfig)) + if agentConfig != nil && len(agentConfig.AllowHostPorts) > 0 { + warning := "sandbox.agent.allow-host-ports has no effect in strict security mode (the default); set sandbox.agent.legacy-security: enable to reach host ports" fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) awfHelpersLog.Printf("Warning: %s", warning) } @@ -607,11 +618,22 @@ func BuildAWFArgs(config AWFCommandConfig) []string { return awfArgs } -func collectAllowedHostPorts(workflowData *WorkflowData, agentConfig *AgentSandboxConfig, includeDefaultPorts bool) []int { - ports := map[int]struct{}{} - for _, port := range collectServiceHostPorts(workflowData) { - ports[port] = struct{}{} +// collectAllowedHostPorts merges the default host-access ports (80, 443, and the +// MCP gateway port) with any explicit sandbox.agent.allow-host-ports values. +// +// This is only called in legacy-security mode: --allow-host-ports requires +// --enable-host-access, which is legacy-only. GitHub Actions services: ports +// are intentionally NOT derived here — AWF's --allow-host-service-ports flag +// (see ExtractServicePortExpressions) is the correct mechanism for reaching +// services, since it resolves the actual (possibly dynamically assigned) host +// port at runtime via ${{ job.services[''].ports[''] }} expressions +// rather than relying on a static port number. +func collectAllowedHostPorts(workflowData *WorkflowData, agentConfig *AgentSandboxConfig) []int { + ports := map[int]struct{}{ + 80: {}, + 443: {}, } + ports[getMCPGatewayPort(workflowData)] = struct{}{} if agentConfig != nil { for _, port := range agentConfig.AllowHostPorts { if port >= minPort && port <= maxPort { @@ -619,14 +641,6 @@ func collectAllowedHostPorts(workflowData *WorkflowData, agentConfig *AgentSandb } } } - if includeDefaultPorts || len(ports) > 0 { - ports[80] = struct{}{} - ports[443] = struct{}{} - ports[getMCPGatewayPort(workflowData)] = struct{}{} - } - if len(ports) == 0 { - return nil - } result := make([]int, 0, len(ports)) for port := range ports { result = append(result, port) diff --git a/pkg/workflow/awf_command_builder_test.go b/pkg/workflow/awf_command_builder_test.go index 9659decd331..dad9a9007ae 100644 --- a/pkg/workflow/awf_command_builder_test.go +++ b/pkg/workflow/awf_command_builder_test.go @@ -138,7 +138,7 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode (default) should not emit --enable-host-access") }) - t.Run("emits service host ports in strict mode without host access", func(t *testing.T) { + t.Run("strict mode ignores services and warns when explicit ports are set", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", WorkflowData: &WorkflowData{ @@ -154,52 +154,21 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { - 5432:5432 `, SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ID: "awf"}, + Agent: &AgentSandboxConfig{ID: "awf", AllowHostPorts: []int{9200}}, }, }, AllowedDomains: "github.com", } - args := BuildAWFArgs(config) + var args []string + stderr := captureStderr(func() { + args = BuildAWFArgs(config) + }) argsStr := strings.Join(args, " ") - assert.Contains(t, argsStr, "--allow-host-ports", "Services should emit --allow-host-ports in strict mode") - assert.Equal(t, "80,443,5432,8080", argValue(args, "--allow-host-ports"), "Should include defaults and the declared service host port") + assert.NotContains(t, argsStr, "--allow-host-ports", "--allow-host-ports requires --enable-host-access, so strict mode (the default) must not emit it") assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode should not imply broad host access") - }) - - t.Run("merges explicit and service host ports sorted and deduped", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - Services: `services: - postgres: - image: postgres:18 - ports: - - 5432:5432 - redis: - image: redis:7 - ports: - - 6379:6379 -`, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - AllowHostPorts: []int{9200, 5432}, - }, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - - assert.Equal(t, "80,443,5432,6379,8080,9200", argValue(args, "--allow-host-ports"), "Should sort and dedupe default, service, and explicit host ports") + assert.Contains(t, stderr, "sandbox.agent.allow-host-ports", "Should warn that allow-host-ports has no effect in strict mode") }) t.Run("skips --allow-host-ports and warns when AWF version is too old", func(t *testing.T) { @@ -217,7 +186,8 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ ID: "awf", - AllowHostPorts: []int{9200}, + LegacySecurity: true, + AllowHostPorts: []int{9000}, }, }, }, @@ -260,7 +230,7 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports in network isolation mode") }) - t.Run("legacy security keeps host access and includes service ports", func(t *testing.T) { + t.Run("legacy security keeps host access and merges explicit ports, ignoring services", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", WorkflowData: &WorkflowData{ @@ -279,6 +249,7 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { Agent: &AgentSandboxConfig{ ID: "awf", LegacySecurity: true, + AllowHostPorts: []int{9000, 80}, }, }, }, @@ -289,32 +260,10 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) { argsStr := strings.Join(args, " ") assert.Contains(t, argsStr, "--enable-host-access", "Legacy mode should still emit broad host access") - assert.Equal(t, "80,443,5432,8080", argValue(args, "--allow-host-ports"), "Legacy mode should merge default and service ports") + assert.Equal(t, "80,443,8080,9000", argValue(args, "--allow-host-ports"), "Legacy mode should merge default and explicit ports; services are reached via --allow-host-service-ports, not a static allowlist") }) } -func TestCollectServiceHostPorts(t *testing.T) { - tests := []struct { - name string - portSpec string - want []int - }{ - {name: "host container mapping", portSpec: "5432:5432", want: []int{5432}}, - {name: "bare port", portSpec: "5432", want: []int{5432}}, - {name: "ip host container mapping", portSpec: "127.0.0.1:5432:5432", want: []int{5432}}, - {name: "udp suffix ignored", portSpec: "5432:5432/udp", want: []int{5432}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - workflowData := &WorkflowData{ - Services: "services:\n db:\n image: postgres:18\n ports:\n - " + tt.portSpec + "\n", - } - - assert.Equal(t, tt.want, collectServiceHostPorts(workflowData)) - }) - } -} - // TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs // only when features.awf-diagnostic-logs is enabled. func TestBuildAWFArgsDiagnosticLogs(t *testing.T) { diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 23c94caa622..21a4d39882a 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -501,6 +501,9 @@ func validateAllowHostPorts(ports []int) error { if port < minPort || port > maxPort { return fmt.Errorf("invalid allow-host-ports value: %d. Expected a TCP port between 1 and 65535. Example: allow-host-ports: [5432]", port) } + if service, dangerous := awfDangerousHostPorts[port]; dangerous { + return fmt.Errorf("invalid allow-host-ports value: %d. This port is blocked by AWF as a dangerous port (%s) and cannot be reached via allow-host-ports even in legacy-security mode. To reach a service on this port, declare it under services: with a port mapping and enable sandbox.agent.legacy-security", port, service) + } } return nil } diff --git a/pkg/workflow/sandbox_validation_test.go b/pkg/workflow/sandbox_validation_test.go index 119ccb69043..b0926f39514 100644 --- a/pkg/workflow/sandbox_validation_test.go +++ b/pkg/workflow/sandbox_validation_test.go @@ -346,7 +346,7 @@ func TestValidateSandboxConfigAllowHostPorts(t *testing.T) { workflowData := &WorkflowData{ Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{AllowHostPorts: []int{5432, 9200}}, + Agent: &AgentSandboxConfig{AllowHostPorts: []int{8081, 9000}}, }, } @@ -367,4 +367,19 @@ func TestValidateSandboxConfigAllowHostPorts(t *testing.T) { assert.Contains(t, err.Error(), "invalid allow-host-ports value: 0") assert.Contains(t, err.Error(), "Example: allow-host-ports: [5432]") }) + + t.Run("dangerous allow-host-ports fails validation", func(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{AllowHostPorts: []int{5432}}, + }, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "a dangerous port should fail validation") + assert.Contains(t, err.Error(), "invalid allow-host-ports value: 5432") + assert.Contains(t, err.Error(), "PostgreSQL") + assert.Contains(t, err.Error(), "services:") + }) } diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index efdda3c5474..4cd11a3b2c4 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -11,7 +11,6 @@ package workflow import ( "fmt" - "sort" "strconv" "strings" @@ -32,6 +31,38 @@ const ( maxPort = 65535 ) +// awfDangerousHostPorts mirrors AWF's DANGEROUS_PORTS list (gh-aw-firewall +// src/squid/policy-manifest.ts). These ports are never allowed via +// --allow-host-ports, even with --enable-host-access: AWF blocks them at +// both the iptables and Squid policy layers to prevent the agent sandbox +// from reaching sensitive services directly. --allow-host-service-ports +// intentionally bypasses this list because it restricts traffic to the host +// gateway only (for GitHub Actions services:), but that flag requires +// sandbox.agent.legacy-security: enable. +var awfDangerousHostPorts = map[int]string{ + 22: "SSH", + 23: "Telnet", + 25: "SMTP", + 110: "POP3", + 143: "IMAP", + 445: "SMB", + 1433: "MS SQL Server", + 1521: "Oracle DB", + 3306: "MySQL", + 3389: "RDP", + 5432: "PostgreSQL", + 5984: "CouchDB", + 6379: "Redis", + 6984: "CouchDB (SSL)", + 8086: "InfluxDB HTTP API", + 8088: "InfluxDB RPC", + 9200: "Elasticsearch HTTP API", + 9300: "Elasticsearch transport", + 27017: "MongoDB", + 27018: "MongoDB sharding", + 28017: "MongoDB web interface", +} + // servicesYAMLWrapper is the top-level YAML wrapper for a services: block. // It provides typed access to the service container map while the YAML is parsed // via goccy/go-yaml with field-level annotations. @@ -136,90 +167,6 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { return result, warnings } -func collectServiceHostPorts(workflowData *WorkflowData) []int { - if workflowData == nil || workflowData.Services == "" { - return nil - } - - var wrapper servicesYAMLWrapper - if err := yaml.Unmarshal([]byte(workflowData.Services), &wrapper); err != nil { - servicePortsLog.Printf("Failed to parse services YAML for host ports: %v", err) - return nil - } - if wrapper.Services == nil { - return nil - } - - seen := map[int]struct{}{} - serviceIDs := sliceutil.SortedKeys(wrapper.Services) - for _, serviceID := range serviceIDs { - svc := wrapper.Services[serviceID] - if svc == nil || svc.Ports == nil { - continue - } - portsList, ok := svc.Ports.([]any) - if !ok { - continue - } - for _, portSpec := range portsList { - if port, ok := parseServiceHostPort(portSpec); ok { - seen[port] = struct{}{} - } - } - } - - ports := make([]int, 0, len(seen)) - for port := range seen { - ports = append(ports, port) - } - sort.Ints(ports) - return ports -} - -func parseServiceHostPort(spec any) (int, bool) { - switch v := spec.(type) { - case int: - return validServiceHostPort(v) - case int64: - return validServiceHostPort(int(v)) - case uint64: - return validServiceHostPort(int(v)) - case float64: - p := int(v) - if float64(p) != v { - return 0, false - } - return validServiceHostPort(p) - case string: - portStr := strings.TrimSpace(v) - if portStr == "" { - return 0, false - } - if idx := strings.LastIndex(portStr, "/"); idx != -1 { - portStr = portStr[:idx] - } - parts := strings.Split(portStr, ":") - hostPart := parts[0] - if len(parts) >= 3 { - hostPart = parts[len(parts)-2] - } - port, err := strconv.Atoi(hostPart) - if err != nil { - return 0, false - } - return validServiceHostPort(port) - default: - return 0, false - } -} - -func validServiceHostPort(port int) (int, bool) { - if port < minPort || port > maxPort { - return 0, false - } - return port, true -} - // parsePortSpec parses a single port specification and returns the container port(s). // Supports formats: // - "5432:5432" (host:container) From 88220ed592cfa9df6df91dc8a2c9df0f3c9161a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:45:56 +0000 Subject: [PATCH 4/4] Address matt-skills review: defense-in-depth port filter, docs anchor, test fixture Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-service-ports.lock.yml | 2 +- pkg/workflow/awf_command_builder.go | 10 ++++++++-- pkg/workflow/frontmatter_extraction_security_test.go | 4 ++-- pkg/workflow/service_ports.go | 8 ++++++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index 2afd595ce0c..069336777c6 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -914,7 +914,7 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --legacy-security --enable-host-access --allow-host-ports 80,443,6379,8080 --skip-pull \ + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --legacy-security --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index ec94b64551c..37269964993 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -636,9 +636,15 @@ func collectAllowedHostPorts(workflowData *WorkflowData, agentConfig *AgentSandb ports[getMCPGatewayPort(workflowData)] = struct{}{} if agentConfig != nil { for _, port := range agentConfig.AllowHostPorts { - if port >= minPort && port <= maxPort { - ports[port] = struct{}{} + if port < minPort || port > maxPort { + continue } + // Defense-in-depth: dangerous ports must never reach --allow-host-ports, + // even if validateAllowHostPorts was bypassed or its call order changes. + if _, dangerous := awfDangerousHostPorts[port]; dangerous { + continue + } + ports[port] = struct{}{} } } result := make([]int, 0, len(ports)) diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 1efde9dd947..59c9338103a 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -144,11 +144,11 @@ func TestExtractAgentSandboxConfigAllowHostPorts(t *testing.T) { config := compiler.extractAgentSandboxConfig(map[string]any{ "id": "awf", - "allow-host-ports": []any{5432, 9200}, + "allow-host-ports": []any{8080, 9090}, }) require.NotNil(t, config, "Should extract agent sandbox config") - assert.Equal(t, []int{5432, 9200}, config.AllowHostPorts) + assert.Equal(t, []int{8080, 9090}, config.AllowHostPorts) } func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index 4cd11a3b2c4..3291c911926 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -32,13 +32,17 @@ const ( ) // awfDangerousHostPorts mirrors AWF's DANGEROUS_PORTS list (gh-aw-firewall -// src/squid/policy-manifest.ts). These ports are never allowed via -// --allow-host-ports, even with --enable-host-access: AWF blocks them at +// src/squid/policy-manifest.ts) as of the pinned AWF release +// constants.DefaultFirewallVersion (v0.27.44). These ports are never allowed +// via --allow-host-ports, even with --enable-host-access: AWF blocks them at // both the iptables and Squid policy layers to prevent the agent sandbox // from reaching sensitive services directly. --allow-host-service-ports // intentionally bypasses this list because it restricts traffic to the host // gateway only (for GitHub Actions services:), but that flag requires // sandbox.agent.legacy-security: enable. +// +// If the pinned AWF version is bumped and its DANGEROUS_PORTS list changes, +// update this map to match; there is no automated sync with upstream. var awfDangerousHostPorts = map[int]string{ 22: "SSH", 23: "Telnet",