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
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
88 changes: 78 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,59 @@ 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 {
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))
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{8080, 9090},
})

require.NotNil(t, config, "Should extract agent sandbox config")
assert.Equal(t, []int{8080, 9090}, config.AllowHostPorts)
}

func TestExtractAgentSandboxConfigModelFallback(t *testing.T) {
compiler := &Compiler{}

Expand Down
Loading
Loading