Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
224da07
feat: default to strict security mode, deprecate sudo/host-access
lpcox Jul 14, 2026
946e5aa
fix: address review feedback on legacy-security PR
lpcox Jul 14, 2026
b89da77
chore: begin review feedback follow-up
Copilot Jul 14, 2026
a29dcc3
fix: opt service-port workflows into legacy security
Copilot Jul 14, 2026
8c420f9
chore: revert incidental lockfile updates
Copilot Jul 14, 2026
c225286
Merge branch 'main' into feat/legacy-security-compiler
github-actions[bot] Jul 14, 2026
e846401
chore: start PR finisher pass
Copilot Jul 14, 2026
3965642
Merge remote-tracking branch 'origin/main' into feat/legacy-security-…
Copilot Jul 14, 2026
3ce6ce6
test: align harness AWF assertion with strict mode
Copilot Jul 14, 2026
abaeea0
Plan unresolved PR feedback
Copilot Jul 14, 2026
fd06a61
Merge remote-tracking branch 'origin/main' into feat/legacy-security-…
Copilot Jul 15, 2026
bb5d597
chore: merge main and recompile workflows
Copilot Jul 15, 2026
8887d4a
Merge remote-tracking branch 'origin/main' into feat/legacy-security-…
Copilot Jul 15, 2026
8e31b97
Revert .lock.yml changes and bump firewall to v0.27.33
lpcox Jul 15, 2026
58d28c0
Merge remote-tracking branch 'origin/main' into feat/legacy-security-…
Copilot Jul 15, 2026
74066f9
test: cover invalid deployment status validation and refresh lockfiles
Copilot Jul 15, 2026
1257034
Revert lockfile updates to main state
Copilot Jul 15, 2026
80cce26
chore: start PR finisher pass
Copilot Jul 15, 2026
1e9186b
Merge remote-tracking branch 'origin/main' into feat/legacy-security-…
Copilot Jul 15, 2026
3998b55
chore: bump awf to v0.27.34 and restore lockfiles
Copilot Jul 15, 2026
27285bb
chore: start PR finisher pass
Copilot Jul 15, 2026
211edee
Merge remote-tracking branch 'refs/remotes/origin/main' into feat/leg…
Copilot Jul 15, 2026
1d8c322
chore: merge main and sync detection analysis lockfile
Copilot Jul 15, 2026
1c81bc4
chore: bump awf firewall containers to v0.27.35
Copilot Jul 16, 2026
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
40 changes: 20 additions & 20 deletions .github/workflows/smoke-service-ports.lock.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion .github/workflows/smoke-service-ports.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ permissions:
name: Smoke Service Ports
engine: copilot
strict: true
sandbox:
agent:
legacy-security: enable
Comment thread
github-actions[bot] marked this conversation as resolved.
services:
redis:
image: redis:7
Expand Down Expand Up @@ -53,7 +56,7 @@ features:

# Smoke Test: Service Ports (Redis)

**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it.
**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. This workflow opts into `legacy-security: enable` so the compiler can configure AWF to allow traffic to the Redis service port.

**IMPORTANT:** Inside AWF's sandbox, you must connect to services via `host.docker.internal` (not `localhost`). The service containers run on the host, and AWF routes traffic through the host gateway. Since the workflow maps port 6379:6379, port 6379 should work. Keep all outputs concise.

Expand Down
55 changes: 55 additions & 0 deletions pkg/cli/compile_service_ports_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
// TestCompileServicePortsWorkflow compiles the canonical test-service-ports.md workflow
// and verifies that the generated lock file contains --allow-host-service-ports with the
// correct ${{ job.services['<id>'].ports['<port>'] }} expressions for every service port.
// The fixture opts into legacy-security because service-port passthrough requires host access.
func TestCompileServicePortsWorkflow(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()
Expand Down Expand Up @@ -99,6 +100,57 @@ This workflow has no services block and should not include --allow-host-service-
}
}

// TestCompileServicePorts_StrictModeWithServices verifies that workflows with services
// still compile in strict mode, but do not emit --allow-host-service-ports unless they
// opt into legacy-security: enable.
func TestCompileServicePorts_StrictModeWithServices(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

testWorkflow := `---
on:
workflow_dispatch:
permissions:
contents: read
engine: copilot
services:
redis:
image: redis:7
ports:
- 6379:6379
---

# Strict Services Workflow

This workflow has services but omits legacy-security: enable, so it should not include
--allow-host-service-ports.
`
testPath := filepath.Join(setup.workflowsDir, "strict-services.md")
if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil {
t.Fatalf("Failed to write workflow: %v", err)
}

cmd := exec.Command(setup.binaryPath, "compile", testPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
}

lockFilePath := filepath.Join(setup.workflowsDir, "strict-services.lock.yml")
lockContent, err := os.ReadFile(lockFilePath)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}

lock := string(lockContent)
if strings.Contains(lock, "--allow-host-service-ports") {
t.Errorf("Lock file should NOT contain --allow-host-service-ports in strict mode\nLock content:\n%s", lock)
}
if strings.Contains(lock, "job.services['redis'].ports['6379']") {
t.Errorf("Lock file should NOT contain service-port expressions in strict mode\nLock content:\n%s", lock)
}
}

// TestCompileServicePorts_HyphenatedServiceID verifies that service IDs containing
// hyphens are emitted with bracket notation (not dot notation) in the compiled lock file.
func TestCompileServicePorts_HyphenatedServiceID(t *testing.T) {
Expand All @@ -111,6 +163,9 @@ on:
permissions:
contents: read
engine: copilot
sandbox:
agent:
legacy-security: enable
services:
my-postgres:
image: postgres:15
Expand Down
8 changes: 6 additions & 2 deletions pkg/cli/workflows/test-service-ports.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
permissions:
contents: read
engine: copilot
sandbox:
agent:
legacy-security: enable
services:
postgres:
image: postgres:15
Expand All @@ -17,8 +20,9 @@ services:

# Test Service Ports

This workflow tests that the compiler automatically generates `--allow-host-service-ports`
from `services:` port mappings.
This workflow tests that the compiler generates `--allow-host-service-ports`
from `services:` port mappings when `legacy-security: enable` opts AWF into
host-access mode.

Expected: the compiled lock file includes `--allow-host-service-ports` with expressions for
both PostgreSQL (port 5432) and Redis (port 6379).
9 changes: 7 additions & 2 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,13 @@ const OTELSentryEndpointSecretName = "GH_AW_OTEL_SENTRY_ENDPOINT"

// AWF (Agentic Workflow Firewall) constants

// AWFDefaultCommand is the default AWF command prefix
const AWFDefaultCommand = "sudo -E awf"
// AWFDefaultCommand is the default AWF command prefix.
// Strict security (no sudo) is the default since AWF v0.27.32.
const AWFDefaultCommand = "awf"

// AWFLegacySecurityCommand is the AWF command prefix for legacy security mode.
// Used when legacy-security: enable is set in frontmatter.
const AWFLegacySecurityCommand = "sudo -E awf"

// AWFProxyLogsDir is the default directory for AWF proxy logs
const AWFProxyLogsDir = "/tmp/gh-aw/sandbox/firewall/logs"
Expand Down
2 changes: 1 addition & 1 deletion pkg/constants/constants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func TestConstantValues(t *testing.T) {
{"CLIExtensionPrefix", string(CLIExtensionPrefix), "gh aw"},
{"DefaultMCPRegistryURL", string(DefaultMCPRegistryURL), "https://api.mcp.github.com/v0.1"},
{"OTELSentryEndpointSecretName", OTELSentryEndpointSecretName, "GH_AW_OTEL_SENTRY_ENDPOINT"},
{"AWFDefaultCommand", AWFDefaultCommand, "sudo -E awf"},
{"AWFDefaultCommand", AWFDefaultCommand, "awf"},
{"AWFProxyLogsDir", AWFProxyLogsDir, "/tmp/gh-aw/sandbox/firewall/logs"},
{"AWFAuditDir", AWFAuditDir, "/tmp/gh-aw/sandbox/firewall/audit"},
{"PreAgentAuditFilePath", PreAgentAuditFilePath, "/tmp/gh-aw/pre-agent-audit.txt"},
Expand Down
6 changes: 3 additions & 3 deletions pkg/constants/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,9 @@ func TestSpec_URLConstants_Values(t *testing.T) {
// TestSpec_AWFConstants_Values validates the documented AWF constants.
// Spec section: "## AWF (Agentic Workflow Firewall) Constants"
func TestSpec_AWFConstants_Values(t *testing.T) {
// From spec: AWFDefaultCommand // "sudo -E awf"
assert.Equal(t, "sudo -E awf", constants.AWFDefaultCommand,
"AWFDefaultCommand should be 'sudo -E awf' as documented")
// From spec: AWFDefaultCommand // "awf" (strict mode default; legacy mode uses AWFLegacySecurityCommand)
assert.Equal(t, "awf", constants.AWFDefaultCommand,
"AWFDefaultCommand should be 'awf' for strict security mode")

// From spec: AWFProxyLogsDir // "/tmp/gh-aw/sandbox/firewall/logs"
assert.Equal(t, "/tmp/gh-aw/sandbox/firewall/logs", constants.AWFProxyLogsDir,
Expand Down
7 changes: 6 additions & 1 deletion pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const DefaultGitHubMCPServerVersion Version = "v1.5.0"
//
// The first recompile regenerates all lock files using the new version; the second recompile
// refreshes the container SHA pins that were resolved during the first pass.
const DefaultFirewallVersion Version = "v0.27.31"
const DefaultFirewallVersion Version = "v0.27.32"

// AWFExcludeEnvMinVersion is the minimum AWF version that supports the --exclude-env flag.
// Workflows pinning an older AWF version must not emit --exclude-env flags or the run will fail.
Expand Down Expand Up @@ -114,6 +114,11 @@ const AWFArcDindMinVersion Version = "v0.27.20"
// containerRuntime field in the container config (gh-aw-firewall#6093).
const AWFContainerRuntimeMinVersion Version = "v0.27.30"

// AWFLegacySecurityMinVersion is the minimum AWF version that supports the
// --legacy-security flag and unconditional API proxy (gh-aw-firewall#6207).
// Workflows pinning an older AWF version must use the old --security-mode compat behavior.
const AWFLegacySecurityMinVersion Version = "v0.27.32"

// DefaultGVisorVersion is the pinned gVisor release used by the compiler-generated
// install step. A specific dated release name is used instead of "latest" to ensure
// reproducible, verifiable installs. Each release provides SHA-512 files for
Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3528,6 +3528,11 @@
}
},
"additionalProperties": false
},
"legacy-security": {
"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."
}
},
"additionalProperties": false
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/agentic_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ func TestClaudeEngineNetworkHookCleanup(t *testing.T) {
result := allStepsStr.String()

// Verify AWF is used instead of hooks
if !strings.Contains(result, "sudo -E awf") {
if !strings.Contains(result, "awf") {
t.Error("Expected AWF wrapper to be used with network permissions")
}

Expand Down Expand Up @@ -483,7 +483,7 @@ func TestClaudeEngineNetworkHookCleanup(t *testing.T) {
result := allStepsStr.String()

// Verify AWF is used
if !strings.Contains(result, "sudo -E awf") {
if !strings.Contains(result, "awf") {
t.Error("Expected AWF to be used even with deny-all policy")
}

Expand Down
6 changes: 4 additions & 2 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,12 @@ type AWFPlatformConfig struct {
}

// AWFAPIProxyConfig is the "apiProxy" section of the AWF config file.
// It maps to the --enable-api-proxy and --*-api-target CLI flags.
// It maps to the apiProxy.* fields in the AWF config schema.
// Note: --enable-api-proxy is deprecated since AWF v0.27.32 (API proxy is always on).
type AWFAPIProxyConfig struct {
// Enabled enables the API proxy sidecar for LLM gateway credential isolation.
// Maps to: --enable-api-proxy
// Since AWF v0.27.32, the API proxy is always enabled; this field is kept
// for backward compatibility with older AWF versions.
Enabled bool `json:"enabled"`

// EnableTokenSteering enables budget-warning system message injection near ET budget exhaustion.
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1443,7 +1443,7 @@ func TestBuildAWFCommand_ConfigFileWithPathSetup(t *testing.T) {
pathSetupIdx := strings.Index(command, "GH_AW_NODE_BIN")
configWriteIdx := strings.Index(command, "awf-config.json")
modelsPathIdx := strings.Index(command, "GH_AW_MODELS_JSON_PATH")
awfIdx := strings.Index(command, "sudo -E awf")
awfIdx := strings.Index(command, "awf ")

assert.GreaterOrEqual(t, pathSetupIdx, 0, "path setup should appear in command")
assert.GreaterOrEqual(t, configWriteIdx, 0, "config file write should appear in command")
Expand Down Expand Up @@ -1548,7 +1548,7 @@ func TestBuildAWFCommand_WritesAgentCLIStartTimestamp(t *testing.T) {
// The timestamp write must appear before the AWF invocation so it captures
// the step start time rather than the time after AWF container setup.
tsIdx := strings.Index(command, AgentCLIStartMsPath)
awfIdx := strings.Index(command, "sudo -E awf")
awfIdx := strings.Index(command, "awf ")
assert.Less(t, tsIdx, awfIdx,
"timestamp write must appear before AWF invocation")

Expand Down
58 changes: 38 additions & 20 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,13 +420,18 @@ fi`,
}

// Add --allow-host-service-ports for services with port mappings.
// This flag requires --legacy-security since it grants host network access.
// This is appended as a raw (expandable) arg because the value contains
// ${{ job.services.<id>.ports['<port>'] }} expressions that include single quotes.
// These expressions are resolved by the GitHub Actions runner before shell execution,
// so they must not be shell-escaped.
if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" {
agentCfg := getAgentConfig(config.WorkflowData)
isLegacyMode := agentCfg != nil && agentCfg.LegacySecurity
if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" && isLegacyMode {
expandableArgs += fmt.Sprintf(` --allow-host-service-ports "%s"`, config.WorkflowData.ServicePortExpressions)
awfHelpersLog.Printf("Added --allow-host-service-ports with %s", config.WorkflowData.ServicePortExpressions)
} else if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" {
awfHelpersLog.Print("Skipping --allow-host-service-ports: requires legacy-security mode")
}

engineCommand := config.EngineCommand
Expand Down Expand Up @@ -586,13 +591,15 @@ fi`,
// The following flags are expressed in the generated JSON config file written by
// BuildAWFCommand and are therefore not emitted here:
// - --allow-domains / --block-domains → network.allowDomains / network.blockDomains
// - --enable-api-proxy → apiProxy.enabled
// - --image-tag → container.imageTag
// - --openai-api-target → apiProxy.targets.openai.host
// - --anthropic-api-target → apiProxy.targets.anthropic.host
// - --copilot-api-target → apiProxy.targets.copilot.host
// - --gemini-api-target → apiProxy.targets.gemini.host
//
// Note: --enable-api-proxy is deprecated in AWF v0.27.32+ (API proxy is always on).
// The apiProxy.enabled field is still emitted in the config file for backward compat.
//
// Parameters:
// - config: AWF command configuration
//
Expand Down Expand Up @@ -672,17 +679,21 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled")
}

if isAWFNetworkIsolationEnabled(config.WorkflowData) {
awfHelpersLog.Print("Skipping host-access flags: sandbox.agent.sudo is false (network isolation mode)")
} else {
// Always add --enable-host-access: needed for the API proxy sidecar
// (to reach host.docker.internal:<port>) and for MCP gateway communication
// Legacy security mode: emit --legacy-security, --enable-host-access, and --allow-host-ports
isLegacy := agentConfig != nil && agentConfig.LegacySecurity
if isLegacy {
Comment thread
github-actions[bot] marked this conversation as resolved.
if awfSupportsLegacySecurity(firewallConfig) {
awfArgs = append(awfArgs, "--legacy-security")
awfHelpersLog.Print("Added --legacy-security (legacy-security: enable in frontmatter)")
} else {
// AWF versions older than v0.27.32 don't support --legacy-security;
// they run in legacy mode by default so the flag is unnecessary.
awfHelpersLog.Printf("Skipping --legacy-security: AWF version %q is older than minimum %s (legacy mode is the default for older versions)", getAWFImageTag(firewallConfig), constants.AWFLegacySecurityMinVersion)
}

awfArgs = append(awfArgs, "--enable-host-access")
awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway")
awfHelpersLog.Print("Added --enable-host-access for legacy security mode")

// 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 &&
Expand All @@ -691,10 +702,10 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
}
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)
awfHelpersLog.Printf("Added --allow-host-ports %s for legacy security mode", hostPorts)
}
} else {
awfHelpersLog.Print("Strict security: skipping host-access flags (default)")
}

// Skip pulling images since they are pre-downloaded
Expand Down Expand Up @@ -780,14 +791,14 @@ func GetAWFCommandPrefix(workflowData *WorkflowData) string {
return agentConfig.Command
}

// When sudo is false (network isolation mode), AWF runs rootless: no sudo needed.
// Strip the "sudo -E " prefix from the default command to get the base binary name.
if isAWFNetworkIsolationEnabled(workflowData) {
awfHelpersLog.Print("Using rootless AWF command (sudo: false, network isolation mode)")
return strings.TrimPrefix(string(constants.AWFDefaultCommand), "sudo -E ")
// Legacy security mode: use sudo for backward compatibility
if agentConfig != nil && agentConfig.LegacySecurity {
awfHelpersLog.Print("Using legacy AWF command (legacy-security: enable)")
return string(constants.AWFLegacySecurityCommand)
}

awfHelpersLog.Print("Using standard AWF command")
// Default strict security: AWF runs rootless (no sudo)
awfHelpersLog.Print("Using standard AWF command (strict security, no sudo)")
return string(constants.AWFDefaultCommand)
}

Expand Down Expand Up @@ -1043,6 +1054,13 @@ func awfSupportsContainerRuntime(firewallConfig *FirewallConfig) bool {
return awfVersionAtLeast(firewallConfig, constants.AWFContainerRuntimeMinVersion)
}

// awfSupportsLegacySecurity returns true when the effective AWF version supports the
// --legacy-security flag (v0.27.32+). Older versions default to legacy mode and do not
// recognize this flag.
func awfSupportsLegacySecurity(firewallConfig *FirewallConfig) bool {
return awfVersionAtLeast(firewallConfig, constants.AWFLegacySecurityMinVersion)
}

// buildArcDindChrootConfigPatchBody returns the Node.js command that patches the AWF
// config file with chroot.binariesSourcePath and chroot.identity.*. It is designed to be
// embedded inside a bash if-block that already guards on DOCKER_HOST=tcp://...
Expand Down
Loading
Loading