Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .github/workflows/smoke-service-ports.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 when legacy-security is enabled.",
"leaf": true
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/guides/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Stage and commit your changes:
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,13 @@ sandbox:
# (optional)
legacy-security: "enable"

# 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: []

# Legacy custom Sandbox Runtime configuration (use agent.config instead). Note:
# Network configuration is controlled by the top-level 'network' field, not here.
# (optional)
Expand Down
27 changes: 27 additions & 0 deletions docs/src/content/docs/reference/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ 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:`)

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.

```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:
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`, and rejects ports AWF always blocks as dangerous (e.g. `22`, `3306`, `5432`, `6379`, `9200`) — reach those through `services:` instead.

#### 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.
Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
82 changes: 72 additions & 10 deletions pkg/workflow/awf_command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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) {
Expand All @@ -521,18 +523,31 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
awfArgs = append(awfArgs, "--enable-host-access")
awfHelpersLog.Print("Added --enable-host-access for legacy security mode")

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
// --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)
}
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)
}
} else {
awfHelpersLog.Print("Strict security: skipping host-access flags (default)")
awfHelpersLog.Print("Strict security: skipping host-access flag (default)")
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)
}
}

// Skip pulling images since they are pre-downloaded
Expand Down Expand Up @@ -603,6 +618,53 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
return awfArgs
}

// 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['<id>'].ports['<port>'] }} 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 {
ports[port] = struct{}{}
}
}
}
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.
//
Expand Down
94 changes: 90 additions & 4 deletions pkg/workflow/awf_command_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
})

Expand All @@ -138,7 +138,40 @@ 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("strict mode ignores services and warns when explicit ports are set", 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", AllowHostPorts: []int{9200}},
},
},
AllowedDomains: "github.com",
}

var args []string
stderr := captureStderr(func() {
args = BuildAWFArgs(config)
})
argsStr := strings.Join(args, " ")

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")
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) {
config := AWFCommandConfig{
EngineName: "copilot",
WorkflowData: &WorkflowData{
Expand All @@ -150,14 +183,25 @@ func TestBuildAWFArgsAllowHostPorts(t *testing.T) {
Version: "v0.25.23",
},
},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{
ID: "awf",
LegacySecurity: true,
AllowHostPorts: []int{9000},
},
},
},
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) {
Expand Down Expand Up @@ -185,6 +229,39 @@ 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 merges explicit ports, ignoring services", 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,
AllowHostPorts: []int{9000, 80},
},
},
},
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,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")
})
}

// TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs
Expand Down Expand Up @@ -674,3 +751,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 ""
}
21 changes: 21 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions pkg/workflow/frontmatter_extraction_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
Loading
Loading