From 9e0513c0f1e0a55964898a49ea8d3be9f7ef13d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:41:40 +0000 Subject: [PATCH 1/8] Initial plan From 81ff5b130c0a0444400344aaf3aa8b1592104f3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:02:09 +0000 Subject: [PATCH 2/8] feat: add sandbox.agent.runtime: gvisor support with install step and AWF config emission Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .changeset/minor-gvisor-runtime-support.md | 15 + pkg/parser/schemas/main_workflow_schema.json | 6 + pkg/workflow/awf_config.go | 14 +- pkg/workflow/copilot_engine_installation.go | 45 +++ pkg/workflow/firewall.go | 19 + .../frontmatter_extraction_security.go | 8 + pkg/workflow/gvisor_test.go | 328 ++++++++++++++++++ pkg/workflow/nodejs.go | 6 + pkg/workflow/sandbox.go | 10 + pkg/workflow/sandbox_validation.go | 31 ++ .../strict_mode_sandbox_validation.go | 4 +- 11 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 .changeset/minor-gvisor-runtime-support.md create mode 100644 pkg/workflow/gvisor_test.go diff --git a/.changeset/minor-gvisor-runtime-support.md b/.changeset/minor-gvisor-runtime-support.md new file mode 100644 index 00000000000..60da540e653 --- /dev/null +++ b/.changeset/minor-gvisor-runtime-support.md @@ -0,0 +1,15 @@ +--- +"gh-aw": minor +--- + +Add `sandbox.agent.runtime: gvisor` frontmatter field for gVisor container runtime support. + +When set to `gvisor`, the compiler: +1. Emits a pre-agent gVisor installation step that downloads, installs, and verifies `runsc` and `containerd-shim-runsc-v1` +2. Passes `container.containerRuntime: "gvisor"` in the AWF stdin config JSON so AWF starts the agent container under the `runsc` runtime + +Compile-time validation rejects incompatible combinations: +- `sandbox.agent.runtime: gvisor` + `runner.topology: arc-dind` (gVisor requires `systemctl restart docker` which is unavailable on ARC DinD runners) +- `sandbox.agent.runtime: gvisor` + `sandbox.agent.sudo: false` (the install step requires root access) + +When `runtime: gvisor` is configured, `sandbox.agent.sudo: true` is required and the `sudo: true` deprecation warning/error is suppressed since gVisor fundamentally requires root. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index da379868dec..661f8038fe2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3461,6 +3461,12 @@ "description": "Enable or disable model fallback for unresolved model selections. Set to false for BYOK Azure OpenAI deployments to prevent deployment-name rewriting. Supports literal boolean or GitHub Actions expression.", "examples": [false, "${{ inputs.model-fallback }}"] }, + "runtime": { + "type": "string", + "description": "Container runtime for the agent container. Use 'gvisor' to run the agent under gVisor's runsc runtime for additional kernel-level isolation. Requires sandbox.agent.sudo: true (root access is needed to install and register runsc). Incompatible with runner.topology: arc-dind.", + "enum": ["gvisor"], + "examples": ["gvisor"] + }, "config": { "type": "object", "description": "Custom sandbox runtime configuration. Note: Network configuration is controlled by the top-level 'network' field, not here.", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index eea7a0bbd2b..dd2b26344a6 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -299,6 +299,11 @@ type AWFContainerConfig struct { // runner and daemon have separate filesystems. // Maps to: --docker-host-path-prefix DockerHostPathPrefix string `json:"dockerHostPathPrefix,omitempty"` + + // ContainerRuntime specifies the OCI runtime for the agent container. + // "gvisor" enables gVisor's runsc runtime for additional kernel-level isolation. + // AWF translates "gvisor" → "runsc" internally. + ContainerRuntime string `json:"containerRuntime,omitempty"` } // AWFLoggingConfig is the "logging" section of the AWF config file. @@ -544,9 +549,11 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { // ── Container section ───────────────────────────────────────────────────── awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData) - if awfImageTag != "" || isArcDindTopology(config.WorkflowData) { + agentRuntime := getAgentContainerRuntime(config.WorkflowData) + if awfImageTag != "" || isArcDindTopology(config.WorkflowData) || agentRuntime != "" { container := &AWFContainerConfig{ - ImageTag: awfImageTag, + ImageTag: awfImageTag, + ContainerRuntime: agentRuntime, } // NOTE: dockerHostPathPrefix is intentionally NOT set for arc-dind topology. // With sysroot-stage active, the Docker daemon can access all needed paths: @@ -560,6 +567,9 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { if awfImageTag != "" { awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag) } + if agentRuntime != "" { + awfConfigLog.Printf("Container section: containerRuntime=%s", agentRuntime) + } } // ── Logging section ────────────────────────────────────────────────────── diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index c1680524aa2..af08e982fb4 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -330,3 +330,48 @@ func generateDockerComposeInstallStep() GitHubActionStep { ` docker compose version`, }) } + +// generateGVisorInstallStep creates a GitHub Actions step that downloads, installs, +// and verifies the gVisor (runsc) container runtime. This step must run BEFORE the +// AWF invocation step so that Docker can start the agent container under runsc. +// +// Key implementation notes: +// - Uses uname -m directly (x86_64, aarch64) — gVisor download URLs use raw arch names. +// - Restarts Docker with `systemctl restart` (NOT reload): Docker's SIGHUP reload does +// not call setHostGatewayIP(), which breaks --add-host host.docker.internal:host-gateway. +// - Downloads both runsc and containerd-shim-runsc-v1; the shim is required for Docker's +// containerd integration. +func generateGVisorInstallStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: Install gVisor (runsc)", + " run: |", + " set -euo pipefail", + "", + ` echo "::group::Install gVisor (runsc)"`, + ` ARCH=$(uname -m)`, + ` URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"`, + ` echo "Downloading runsc for ${ARCH}..."`, + ` curl -fsSL "${URL}/runsc" -o /tmp/runsc`, + ` curl -fsSL "${URL}/containerd-shim-runsc-v1" -o /tmp/containerd-shim-runsc-v1`, + ` sudo install -m 755 /tmp/runsc /usr/local/bin/runsc`, + ` sudo install -m 755 /tmp/containerd-shim-runsc-v1 /usr/local/bin/containerd-shim-runsc-v1`, + ` runsc --version`, + ` echo "::endgroup::"`, + "", + ` echo "::group::Register runsc as Docker runtime"`, + ` sudo runsc install`, + ` # IMPORTANT: Must use restart (not reload).`, + ` # Docker's SIGHUP reload does NOT call setHostGatewayIP(), so`, + ` # --add-host host.docker.internal:host-gateway breaks for any`, + ` # container started after a reload-only config change.`, + ` sudo systemctl restart docker`, + ` echo "Docker runtimes:"`, + ` docker info --format '{{.Runtimes}}' || docker info | grep -i runtime`, + ` echo "::endgroup::"`, + "", + ` echo "::group::Verify gVisor works"`, + ` docker run --rm --runtime=runsc hello-world`, + ` echo "✅ gVisor runtime verified"`, + ` echo "::endgroup::"`, + }) +} diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index 6181a6ad121..9cc4fed7b23 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -115,6 +115,25 @@ func getAgentConfig(workflowData *WorkflowData) *AgentSandboxConfig { return workflowData.SandboxConfig.Agent } +// getAgentContainerRuntime returns the container runtime string for the AWF config, +// or an empty string if no custom runtime is configured. +func getAgentContainerRuntime(workflowData *WorkflowData) string { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return "" + } + return string(agentConfig.Runtime) +} + +// isGVisorRuntime returns true when the agent container should use gVisor (runsc). +func isGVisorRuntime(workflowData *WorkflowData) bool { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return false + } + return agentConfig.Runtime == AgentRuntimeGVisor +} + func isAWFNetworkIsolationEnabled(workflowData *WorkflowData) bool { agentConfig := getAgentConfig(workflowData) if agentConfig == nil || agentConfig.Disabled { diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 3b667260907..3453bc32d62 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -266,6 +266,14 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract runtime (container runtime for the agent container) + if runtimeVal, hasRuntime := agentObj["runtime"]; hasRuntime { + if runtimeStr, ok := runtimeVal.(string); ok { + agentConfig.Runtime = AgentRuntime(runtimeStr) + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.runtime: %s", runtimeStr) + } + } + // 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/gvisor_test.go b/pkg/workflow/gvisor_test.go new file mode 100644 index 00000000000..68870471451 --- /dev/null +++ b/pkg/workflow/gvisor_test.go @@ -0,0 +1,328 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGenerateGVisorInstallStep verifies that the gVisor install step contains +// the expected content from the reference implementation. +func TestGenerateGVisorInstallStep(t *testing.T) { + step := generateGVisorInstallStep() + require.NotEmpty(t, step, "gVisor install step must not be empty") + + content := strings.Join(step, "\n") + + assert.Contains(t, content, "Install gVisor (runsc)", "step should have a recognizable name") + + // Architecture detection: must use uname -m, not hardcoded amd64/arm64. + assert.Contains(t, content, "uname -m", "must detect architecture via uname -m") + assert.NotContains(t, content, "amd64", "must NOT remap architecture to amd64") + assert.NotContains(t, content, "arm64", "must NOT remap architecture to arm64") + + // Both binaries must be downloaded. + assert.Contains(t, content, "runsc", "must download runsc binary") + assert.Contains(t, content, "containerd-shim-runsc-v1", "must download containerd-shim-runsc-v1") + + // Must use gVisor's official download URL. + assert.Contains(t, content, "storage.googleapis.com/gvisor", "must use official gVisor download URL") + + // Must install binaries to system path (requires sudo). + assert.Contains(t, content, "sudo install", "must install binaries with sudo") + assert.Contains(t, content, "/usr/local/bin/runsc", "must install runsc to /usr/local/bin") + + // Must register the runtime with Docker. + assert.Contains(t, content, "sudo runsc install", "must register runsc with Docker via runsc install") + + // Must use systemctl restart (NOT reload) to restart Docker. + assert.Contains(t, content, "systemctl restart docker", "must restart Docker with systemctl restart") + assert.NotContains(t, content, "systemctl reload docker", "must NOT use systemctl reload (breaks host-gateway DNS)") + + // Must verify the runtime works. + assert.Contains(t, content, "docker run --rm --runtime=runsc", "must verify gVisor runtime with a test container") +} + +// TestGVisorInstallStepOrderInBuildNpmEngineInstallStepsWithAWF verifies that the +// gVisor install step is emitted BEFORE the AWF install step. +func TestGVisorInstallStepOrderInBuildNpmEngineInstallStepsWithAWF(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: false, // sudo: true (required for gVisor) + SudoExplicitlyEnabled: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + + steps := BuildNpmEngineInstallStepsWithAWF(nil, workflowData) + require.NotEmpty(t, steps, "must generate installation steps") + + // Find index of gVisor step and AWF step. + gvisorIdx := -1 + awfIdx := -1 + for i, step := range steps { + content := strings.Join(step, "\n") + if strings.Contains(content, "Install gVisor") { + gvisorIdx = i + } + if strings.Contains(content, "install_awf_binary.sh") { + awfIdx = i + } + } + + require.NotEqual(t, -1, gvisorIdx, "gVisor install step must be present") + require.NotEqual(t, -1, awfIdx, "AWF install step must be present") + assert.Less(t, gvisorIdx, awfIdx, "gVisor step must come BEFORE AWF install step") +} + +// TestGVisorAWFConfigJSON verifies that sandbox.agent.runtime: gvisor causes +// containerRuntime: "gvisor" to appear in the AWF config JSON. +func TestGVisorAWFConfigJSON(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: false, + SudoExplicitlyEnabled: true, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + assert.Contains(t, jsonStr, `"containerRuntime":"gvisor"`, + "AWF config JSON must include containerRuntime: gvisor") +} + +// TestGVisorAWFConfigJSONAbsentByDefault verifies that containerRuntime is not +// emitted when runtime is not configured. +func TestGVisorAWFConfigJSONAbsentByDefault(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + assert.NotContains(t, jsonStr, `"containerRuntime"`, + "containerRuntime must be absent when runtime is not configured") +} + +// TestGVisorValidation_ArcDindIncompatible verifies that gVisor + arc-dind is a +// compile-time error. +func TestGVisorValidation_ArcDindIncompatible(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: false, + SudoExplicitlyEnabled: true, + }, + }, + RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "gVisor + arc-dind must produce a compile-time error") + assert.Contains(t, err.Error(), "arc-dind", "error must mention arc-dind") + assert.Contains(t, err.Error(), "gvisor", "error must mention gvisor") +} + +// TestGVisorValidation_SudoFalseIncompatible verifies that gVisor + sudo:false (default) +// is a compile-time error. +func TestGVisorValidation_SudoFalseIncompatible(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: true, // sudo: false (default or explicit) + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "gVisor + sudo:false must produce a compile-time error") + assert.Contains(t, err.Error(), "gvisor", "error must mention gvisor") + assert.Contains(t, err.Error(), "sudo", "error must mention sudo") +} + +// TestGVisorValidation_ValidCombination verifies that a valid gVisor configuration +// (with sudo: true on a non-arc-dind runner) passes validation. +func TestGVisorValidation_ValidCombination(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: false, // sudo: true + SudoExplicitlyEnabled: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + assert.NoError(t, err, "gVisor + sudo:true on a standard runner must pass validation") +} + +// TestGVisorStrictModeSudoTrueSuppressed verifies that sandbox.agent.sudo: true +// does NOT produce a strict-mode error or warning when runtime: gvisor is configured. +func TestGVisorStrictModeSudoTrueSuppressed(t *testing.T) { + sandboxConfig := &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: false, + SudoExplicitlyEnabled: true, + }, + } + + compiler := NewCompiler() + compiler.strictMode = true + initialWarnings := compiler.GetWarningCount() + + err := compiler.validateStrictSandboxCustomization(sandboxConfig) + require.NoError(t, err, "sudo:true + runtime:gvisor must not produce a strict-mode error") + assert.Equal(t, initialWarnings, compiler.GetWarningCount(), "sudo:true + runtime:gvisor must not produce a warning") +} + +// TestGVisorFrontmatterExtraction verifies end-to-end that a workflow with +// sandbox.agent.runtime: gvisor compiles correctly and produces the expected output. +func TestGVisorFrontmatterExtraction(t *testing.T) { + workflowsDir := t.TempDir() + + markdown := `--- +on: + workflow_dispatch: +engine: copilot +strict: false +network: + allowed: + - "example.com" +sandbox: + agent: + id: awf + runtime: gvisor + sudo: true +--- + +# Test gVisor Runtime +` + + testFile := filepath.Join(workflowsDir, "test-gvisor.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err) + + compiler := NewCompiler() + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "compilation with runtime: gvisor must succeed") + + lockContent, err := os.ReadFile(filepath.Join(workflowsDir, "test-gvisor.lock.yml")) + require.NoError(t, err) + lockStr := string(lockContent) + + // gVisor install step must be present. + assert.Contains(t, lockStr, "Install gVisor", "compiled workflow must include gVisor install step") + + // AWF install step must also be present. + assert.Contains(t, lockStr, "Install AWF binary", "compiled workflow must include AWF install step") + + // containerRuntime: gvisor must appear in the AWF config JSON. + // The AWF config is embedded as escaped JSON in a printf command in the lock file. + assert.Contains(t, lockStr, `\"containerRuntime\":\"gvisor\"`, + "compiled workflow must pass containerRuntime: gvisor to AWF") + + // gVisor step must appear before AWF step. + gvisorPos := strings.Index(lockStr, "Install gVisor") + awfPos := strings.Index(lockStr, "Install AWF binary") + assert.Less(t, gvisorPos, awfPos, "gVisor install step must precede AWF install step in compiled YAML") +} + +// TestIsGVisorRuntime verifies the isGVisorRuntime helper. +func TestIsGVisorRuntime(t *testing.T) { + t.Run("returns false for nil workflow data", func(t *testing.T) { + assert.False(t, isGVisorRuntime(nil)) + }) + + t.Run("returns false when no sandbox config", func(t *testing.T) { + assert.False(t, isGVisorRuntime(&WorkflowData{})) + }) + + t.Run("returns false when runtime is not gvisor", func(t *testing.T) { + assert.False(t, isGVisorRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + })) + }) + + t.Run("returns false when agent is disabled", func(t *testing.T) { + assert.False(t, isGVisorRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + Disabled: true, + }, + }, + })) + }) + + t.Run("returns true when runtime is gvisor", func(t *testing.T) { + assert.True(t, isGVisorRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + }, + }, + })) + }) +} diff --git a/pkg/workflow/nodejs.go b/pkg/workflow/nodejs.go index af463cffd27..bc04bb4904c 100644 --- a/pkg/workflow/nodejs.go +++ b/pkg/workflow/nodejs.go @@ -153,6 +153,12 @@ func BuildNpmEngineInstallStepsWithAWF(npmSteps []GitHubActionStep, workflowData if firewallConfig != nil { awfVersion = firewallConfig.Version } + + // gVisor must be installed and registered BEFORE AWF starts the agent container. + if isGVisorRuntime(workflowData) { + steps = append(steps, generateGVisorInstallStep()) + } + awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) if len(awfInstall) > 0 { steps = append(steps, awfInstall) diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index c1f9412f83c..51a6ec0c937 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -45,12 +45,22 @@ type SandboxConfig struct { Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) } +// AgentRuntime represents the container runtime to use for the agent container. +type AgentRuntime string + +const ( + // AgentRuntimeGVisor runs the agent container under gVisor's runsc runtime for + // additional kernel-level isolation. Requires root access for installation. + AgentRuntimeGVisor AgentRuntime = "gvisor" +) + // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) + Runtime AgentRuntime `yaml:"runtime,omitempty"` // Container runtime for the agent container (e.g., "gvisor") 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. Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index d8c876ceeb9..76c37211c18 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -106,6 +106,37 @@ func validateSandboxConfig(workflowData *WorkflowData) error { } } + // Validate gVisor runtime compatibility + if agentConfig != nil && agentConfig.Runtime == AgentRuntimeGVisor { + // gVisor is incompatible with ARC/DinD topology: the runner has no access to the + // DinD sidecar's daemon config or systemd, so runsc install + systemctl restart + // cannot succeed. + if isArcDindTopology(workflowData) { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeGVisor), + "gvisor is incompatible with runner.topology: arc-dind", + "gVisor requires registering the runsc runtime with Docker via systemctl, which "+ + "is not possible on ARC DinD runners where the Docker daemon runs in a sidecar. "+ + "Remove sandbox.agent.runtime: gvisor or change runner.topology.", + ) + } + + // gVisor installation requires root access (sudo install, sudo runsc install, + // sudo systemctl restart docker). It cannot be combined with sudo: false. + if agentConfig.NetworkIsolation { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeGVisor), + "gvisor requires root access for installation and cannot be combined with sandbox.agent.sudo: false", + "The gVisor install step uses sudo to install runsc and restart Docker. "+ + "Set sandbox.agent.sudo: true to allow root access, "+ + "or remove sandbox.agent.runtime: gvisor.", + ) + } + sandboxValidationLog.Print("gVisor runtime configured — sudo access and topology checks passed") + } + // Validate config structure if provided (deprecated - was only for SRT) if sandboxConfig.Config != nil { // Config is no longer used - SRT removed diff --git a/pkg/workflow/strict_mode_sandbox_validation.go b/pkg/workflow/strict_mode_sandbox_validation.go index 22ccb3407d0..aafc7e24506 100644 --- a/pkg/workflow/strict_mode_sandbox_validation.go +++ b/pkg/workflow/strict_mode_sandbox_validation.go @@ -45,7 +45,9 @@ func (c *Compiler) validateStrictSandboxCustomization(sandboxConfig *SandboxConf if agent := sandboxConfig.Agent; agent != nil { // sandbox.agent.sudo: true is deprecated regardless of strict mode. // It is an error in strict mode and a warning otherwise. - if agent.SudoExplicitlyEnabled { + // Exception: sandbox.agent.runtime: gvisor requires root access for installation, + // so sudo: true is implicitly required and the deprecation warning is suppressed. + if agent.SudoExplicitlyEnabled && agent.Runtime != AgentRuntimeGVisor { const sudoTrueMsg = "sandbox.agent.sudo: true re-enables host-access (sudo) mode. " + "The default is now sudo: false (network isolation). " + "Remove 'sudo: true' to use the secure default. " + From 35185d8ae55ab7d5fbdd71d8577622e659ac9ca0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:07:00 +0000 Subject: [PATCH 3/8] fix: pre-pull hello-world image in gVisor verify step; fix em dash in log message Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/workflow/copilot_engine_installation.go | 1 + pkg/workflow/gvisor_test.go | 3 ++- pkg/workflow/sandbox_validation.go | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index af08e982fb4..d5ce03bef78 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -370,6 +370,7 @@ func generateGVisorInstallStep() GitHubActionStep { ` echo "::endgroup::"`, "", ` echo "::group::Verify gVisor works"`, + ` docker pull hello-world`, ` docker run --rm --runtime=runsc hello-world`, ` echo "✅ gVisor runtime verified"`, ` echo "::endgroup::"`, diff --git a/pkg/workflow/gvisor_test.go b/pkg/workflow/gvisor_test.go index 68870471451..2251e52df61 100644 --- a/pkg/workflow/gvisor_test.go +++ b/pkg/workflow/gvisor_test.go @@ -45,7 +45,8 @@ func TestGenerateGVisorInstallStep(t *testing.T) { assert.Contains(t, content, "systemctl restart docker", "must restart Docker with systemctl restart") assert.NotContains(t, content, "systemctl reload docker", "must NOT use systemctl reload (breaks host-gateway DNS)") - // Must verify the runtime works. + // Must verify the runtime works (pre-pull to avoid network dependency during test run). + assert.Contains(t, content, "docker pull hello-world", "must pre-pull hello-world image") assert.Contains(t, content, "docker run --rm --runtime=runsc", "must verify gVisor runtime with a test container") } diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 76c37211c18..ee4ae7c9216 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -134,7 +134,7 @@ func validateSandboxConfig(workflowData *WorkflowData) error { "or remove sandbox.agent.runtime: gvisor.", ) } - sandboxValidationLog.Print("gVisor runtime configured — sudo access and topology checks passed") + sandboxValidationLog.Print("gVisor runtime configured -- sudo access and topology checks passed") } // Validate config structure if provided (deprecated - was only for SRT) From 90c25bb04ea9150a3b4afe7b4974ddd77aedcb07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:39:31 +0000 Subject: [PATCH 4/8] docs(adr): add draft ADR-44796 for gVisor container runtime support --- ...796-gvisor-runtime-for-agent-containers.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/adr/44796-gvisor-runtime-for-agent-containers.md diff --git a/docs/adr/44796-gvisor-runtime-for-agent-containers.md b/docs/adr/44796-gvisor-runtime-for-agent-containers.md new file mode 100644 index 00000000000..05266334dc8 --- /dev/null +++ b/docs/adr/44796-gvisor-runtime-for-agent-containers.md @@ -0,0 +1,52 @@ +# ADR-44796: Use gVisor (runsc) as an Optional Container Runtime for Agent Containers + +**Date**: 2026-07-10 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +Agent containers in gh-aw run under a standard Docker runtime backed by Linux cgroups and namespaces. This provides process-level isolation but leaves the host kernel's system call surface fully accessible to agent code. Any vulnerability in a system call handler can be exploited to escape the container. For higher-assurance workloads, operators need a runtime that interposes on all syscalls and runs them through a user-space kernel, substantially reducing the kernel attack surface exposed to untrusted agent code. The AWF daemon already supports `container.containerRuntime: "gvisor"` in its stdin config (gh-aw-firewall#6093), but the compiler had no way to emit that setting or install the runtime on the runner. This PR wires up the missing compiler side: a new `sandbox.agent.runtime` frontmatter field, an auto-generated pre-agent gVisor install step, and the AWF config JSON plumbing that passes `containerRuntime: "gvisor"` to AWF. + +### Decision + +We will add `sandbox.agent.runtime: gvisor` as an optional frontmatter field. When set, the compiler (1) emits a GitHub Actions step before the AWF install step that downloads `runsc` and `containerd-shim-runsc-v1` from `storage.googleapis.com/gvisor`, installs them to `/usr/local/bin`, registers them with Docker via `sudo runsc install`, restarts Docker with `systemctl restart`, and verifies the runtime with a test container, and (2) passes `containerRuntime: "gvisor"` in the AWF stdin config JSON so AWF starts the agent container under `runsc`. Two incompatible combinations are rejected at compile time: `runtime: gvisor` + `runner.topology: arc-dind` (systemd unavailable on ARC DinD) and `runtime: gvisor` + `sudo: false` (the install step requires root). The `sudo: true` deprecation warning is suppressed when `runtime: gvisor` is set, since gVisor fundamentally requires root for installation. + +### Alternatives Considered + +#### Alternative 1: Standard Docker runtime (status quo) + +Continue using only Linux cgroups/namespaces for agent container isolation without adding a new runtime option. System calls from inside the container reach the host kernel directly. This approach requires zero compiler changes and no runner setup time, and it already satisfies many workloads. It was rejected because it does not meet the kernel-level isolation requirement: a single exploited syscall can escape the container, and operators running sensitive workloads need a stronger isolation boundary. + +#### Alternative 2: Kata Containers + +Kata Containers provide VM-level isolation — each container runs in a lightweight QEMU/Firecracker VM with a dedicated kernel. This gives stronger isolation guarantees than gVisor in some threat models. It was not chosen because: (a) Kata requires KVM hardware virtualization support, which is not available in all GitHub-hosted runner configurations; (b) the AWF daemon already has native support for `containerRuntime: "gvisor"` but not for Kata; and (c) gVisor's installation footprint (two binaries + `runsc install`) is far smaller than Kata's, reducing runner setup time and dependency surface. + +#### Alternative 3: Seccomp / AppArmor profiles + +Reduce the syscall surface by attaching a restrictive seccomp profile or AppArmor policy to the agent container, without changing the OCI runtime. This has near-zero overhead and no special hardware requirements. It was not chosen because it still routes all permitted syscalls through the real host kernel — a zero-day in a whitelisted syscall path remains exploitable. Profiles also require ongoing maintenance as agent behavior evolves, and they do not satisfy an isolation requirement that explicitly demands a user-space kernel intercept layer. + +### Consequences + +#### Positive +- Agent containers configured with `runtime: gvisor` run under gVisor's `runsc` OCI runtime, which interposes on all system calls via a user-space kernel, substantially reducing the host kernel attack surface. +- Compile-time validation catches the two known incompatible combinations (`arc-dind`, `sudo: false`) before a workflow is submitted, giving authors a clear error message rather than a runtime failure. +- The change is fully backward-compatible: existing workflows that do not set `sandbox.agent.runtime` are unaffected and continue to run under the default Docker runtime. +- The `sudo: true` deprecation warning is suppressed when gVisor is configured, avoiding a misleading warning for a case where `sudo: true` is not just acceptable but required. + +#### Negative +- `runtime: gvisor` requires `sudo: true`, which disables AWF's network-isolation mode (`--network-isolation`). Operators gain kernel-level isolation but lose the network-isolation guarantee unless both are layered via other mechanisms. This is a hard trade-off imposed by gVisor's requirement for root during installation. +- `runtime: gvisor` is incompatible with `runner.topology: arc-dind`, restricting which runner configurations can use this feature. +- The gVisor install step adds observable setup time (binary downloads, Docker restart, verification container run) to every workflow execution that enables this option. +- Introducing a special case in `validateStrictSandboxCustomization` for `AgentRuntimeGVisor` increases the complexity of the strict-mode validation path. + +#### Neutral +- The `systemctl restart docker` (rather than `reload`) constraint documented in the implementation will need to be preserved in future modifications to the install step: Docker's SIGHUP reload does not call `setHostGatewayIP()`, which breaks `--add-host host.docker.internal:host-gateway`. +- AWF translates the `"gvisor"` string in its config to `"runsc"` internally; the compiler intentionally passes the human-readable label rather than the internal runtime name. +- Future container runtime options (e.g., Kata) can follow the same `AgentRuntime` type + `sandbox.agent.runtime` enum pattern established here. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 6fb0b2b4d3aeddcd49e79c5ca89200f8c7587642 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:42:09 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20address=20review=20threads=20?= =?UTF-8?q?=E2=80=94=20remove=20sudo=20requirement,=20add=20Codex=20gVisor?= =?UTF-8?q?=20path,=20gate=20containerRuntime=20by=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/constants/README.md | 1 + pkg/constants/version_constants.go | 7 ++ pkg/workflow/awf_config.go | 8 ++ pkg/workflow/awf_helpers.go | 7 ++ pkg/workflow/codex_engine.go | 5 + pkg/workflow/gvisor_test.go | 95 ++++++++++++------- pkg/workflow/sandbox_validation.go | 14 +-- .../strict_mode_sandbox_validation.go | 4 +- 8 files changed, 89 insertions(+), 52 deletions(-) diff --git a/pkg/constants/README.md b/pkg/constants/README.md index 01d9548b715..555e4935dda 100644 --- a/pkg/constants/README.md +++ b/pkg/constants/README.md @@ -321,6 +321,7 @@ constants.AWFAllowHostPortsMinVersion // "v0.25.24" — minimum AWF for --allo constants.AWFDockerHostPathPrefixMinVersion // "v0.25.43" — minimum AWF for --docker-host-path-prefix constants.AWFTokenSteeringMinVersion // "v0.25.44" — minimum AWF for token steering support constants.AWFChrootConfigMinVersion // "v0.27.1" — minimum AWF for chroot.binariesSourcePath and identity.* +constants.AWFContainerRuntimeMinVersion // "v0.28.0" — minimum AWF for containerRuntime in container config (gh-aw-firewall#6093; TODO: update on release) constants.CopilotNoAskUserMinVersion // "1.0.19" — minimum Copilot CLI for --no-ask-user constants.MCPGIntegrityReactionsMinVersion // "v0.2.18" — minimum MCPG for integrity-reactions policy ``` diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index 5a8b0a3462a..26bee4247ed 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -110,6 +110,13 @@ const AWFChrootConfigMinVersion Version = "v0.27.1" // the agent container from starting in split-filesystem ARC/DinD environments. const AWFArcDindMinVersion Version = "v0.27.20" +// AWFContainerRuntimeMinVersion is the minimum AWF version that supports the +// containerRuntime field in the container config (gh-aw-firewall#6093). +// TODO: update this constant to the actual release version once gh-aw-firewall#6093 lands. +// Until then, no released AWF binary accepts containerRuntime, so the compiler must not +// emit the field for workflows pinned to any currently-available version. +const AWFContainerRuntimeMinVersion Version = "v0.28.0" + // CopilotNoAskUserMinVersion is the minimum Copilot CLI version that supports the --no-ask-user // flag, which enables fully autonomous agentic runs by suppressing interactive prompts. // Workflows using an older Copilot CLI version must not emit --no-ask-user or the run will fail. diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index dd2b26344a6..fb7769533bd 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -550,6 +550,14 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { // ── Container section ───────────────────────────────────────────────────── awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData) agentRuntime := getAgentContainerRuntime(config.WorkflowData) + // containerRuntime is only emitted when the effective AWF version supports it. + // Gate here to avoid sending an unrecognised field to older AWF binaries. + if !awfSupportsContainerRuntime(firewallConfig) { + if agentRuntime != "" { + awfConfigLog.Printf("Skipping containerRuntime: AWF version %q requires at least %s (gh-aw-firewall#6093)", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) + } + agentRuntime = "" + } if awfImageTag != "" || isArcDindTopology(config.WorkflowData) || agentRuntime != "" { container := &AWFContainerConfig{ ImageTag: awfImageTag, diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 573ab2b591c..9ef888205eb 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -1026,6 +1026,13 @@ func awfSupportsChrootConfig(firewallConfig *FirewallConfig) bool { return awfVersionAtLeast(firewallConfig, constants.AWFChrootConfigMinVersion) } +// awfSupportsContainerRuntime returns true when the effective AWF version supports the +// containerRuntime field in the container config (gh-aw-firewall#6093). +// The field must not be emitted for older versions that do not recognise it. +func awfSupportsContainerRuntime(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFContainerRuntimeMinVersion) +} + // 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://... diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index f72ed1208d5..2b05f87743c 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -132,6 +132,11 @@ func (e *CodexEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA awfVersion = firewallConfig.Version } + // gVisor must be installed and registered BEFORE AWF starts the agent container. + if isGVisorRuntime(workflowData) { + steps = append(steps, generateGVisorInstallStep()) + } + // Install AWF binary (or skip if custom command is specified) awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) if len(awfInstall) > 0 { diff --git a/pkg/workflow/gvisor_test.go b/pkg/workflow/gvisor_test.go index 2251e52df61..4da90f73177 100644 --- a/pkg/workflow/gvisor_test.go +++ b/pkg/workflow/gvisor_test.go @@ -56,10 +56,8 @@ func TestGVisorInstallStepOrderInBuildNpmEngineInstallStepsWithAWF(t *testing.T) workflowData := &WorkflowData{ SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ID: "awf", - Runtime: AgentRuntimeGVisor, - NetworkIsolation: false, // sudo: true (required for gVisor) - SudoExplicitlyEnabled: true, + ID: "awf", + Runtime: AgentRuntimeGVisor, }, }, NetworkPermissions: &NetworkPermissions{ @@ -89,7 +87,8 @@ func TestGVisorInstallStepOrderInBuildNpmEngineInstallStepsWithAWF(t *testing.T) } // TestGVisorAWFConfigJSON verifies that sandbox.agent.runtime: gvisor causes -// containerRuntime: "gvisor" to appear in the AWF config JSON. +// containerRuntime: "gvisor" to appear in the AWF config JSON when the effective AWF +// version is at or above AWFContainerRuntimeMinVersion. func TestGVisorAWFConfigJSON(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", @@ -97,14 +96,13 @@ func TestGVisorAWFConfigJSON(t *testing.T) { WorkflowData: &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, + // Pin a version that supports containerRuntime (AWFContainerRuntimeMinVersion = v0.28.0). + Firewall: &FirewallConfig{Enabled: true, Version: "v0.28.0"}, }, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ID: "awf", - Runtime: AgentRuntimeGVisor, - NetworkIsolation: false, - SudoExplicitlyEnabled: true, + ID: "awf", + Runtime: AgentRuntimeGVisor, }, }, }, @@ -114,11 +112,37 @@ func TestGVisorAWFConfigJSON(t *testing.T) { require.NoError(t, err) assert.Contains(t, jsonStr, `"containerRuntime":"gvisor"`, - "AWF config JSON must include containerRuntime: gvisor") + "AWF config JSON must include containerRuntime: gvisor when version supports it") } -// TestGVisorAWFConfigJSONAbsentByDefault verifies that containerRuntime is not -// emitted when runtime is not configured. +// TestGVisorAWFConfigJSONVersionGated verifies that containerRuntime is NOT emitted +// when the effective AWF version predates AWFContainerRuntimeMinVersion, even if +// sandbox.agent.runtime: gvisor is set. +func TestGVisorAWFConfigJSONVersionGated(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + // Default version (v0.27.29) predates containerRuntime support. + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeGVisor, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + assert.NotContains(t, jsonStr, `"containerRuntime"`, + "containerRuntime must not be emitted when AWF version predates support") +} func TestGVisorAWFConfigJSONAbsentByDefault(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", @@ -168,9 +192,10 @@ func TestGVisorValidation_ArcDindIncompatible(t *testing.T) { assert.Contains(t, err.Error(), "gvisor", "error must mention gvisor") } -// TestGVisorValidation_SudoFalseIncompatible verifies that gVisor + sudo:false (default) -// is a compile-time error. -func TestGVisorValidation_SudoFalseIncompatible(t *testing.T) { +// TestGVisorValidation_SudoFalseAllowed verifies that gVisor + sudo:false (default) is +// a valid combination. The gVisor install step invokes sudo in shell commands directly, +// independently of sandbox.agent.sudo. +func TestGVisorValidation_SudoFalseAllowed(t *testing.T) { workflowData := &WorkflowData{ SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ @@ -186,21 +211,19 @@ func TestGVisorValidation_SudoFalseIncompatible(t *testing.T) { } err := validateSandboxConfig(workflowData) - require.Error(t, err, "gVisor + sudo:false must produce a compile-time error") - assert.Contains(t, err.Error(), "gvisor", "error must mention gvisor") - assert.Contains(t, err.Error(), "sudo", "error must mention sudo") + require.NoError(t, err, "gVisor + sudo:false must be a valid combination") } // TestGVisorValidation_ValidCombination verifies that a valid gVisor configuration -// (with sudo: true on a non-arc-dind runner) passes validation. +// passes validation (sandbox validation does not reject gVisor; sudo: true is a separate +// deprecation check in strict-mode validation). func TestGVisorValidation_ValidCombination(t *testing.T) { workflowData := &WorkflowData{ SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ID: "awf", - Runtime: AgentRuntimeGVisor, - NetworkIsolation: false, // sudo: true - SudoExplicitlyEnabled: true, + ID: "awf", + Runtime: AgentRuntimeGVisor, + NetworkIsolation: true, // sudo: false (default) }, }, NetworkPermissions: &NetworkPermissions{ @@ -210,12 +233,13 @@ func TestGVisorValidation_ValidCombination(t *testing.T) { } err := validateSandboxConfig(workflowData) - assert.NoError(t, err, "gVisor + sudo:true on a standard runner must pass validation") + assert.NoError(t, err, "gVisor on a standard runner must pass validation") } -// TestGVisorStrictModeSudoTrueSuppressed verifies that sandbox.agent.sudo: true -// does NOT produce a strict-mode error or warning when runtime: gvisor is configured. -func TestGVisorStrictModeSudoTrueSuppressed(t *testing.T) { +// TestGVisorStrictModeSudoTrueError verifies that sandbox.agent.sudo: true combined +// with runtime: gvisor still produces a strict-mode error. The gVisor install step +// uses shell-level sudo directly and does not require AWF itself to run as sudo. +func TestGVisorStrictModeSudoTrueError(t *testing.T) { sandboxConfig := &SandboxConfig{ Agent: &AgentSandboxConfig{ ID: "awf", @@ -227,11 +251,10 @@ func TestGVisorStrictModeSudoTrueSuppressed(t *testing.T) { compiler := NewCompiler() compiler.strictMode = true - initialWarnings := compiler.GetWarningCount() err := compiler.validateStrictSandboxCustomization(sandboxConfig) - require.NoError(t, err, "sudo:true + runtime:gvisor must not produce a strict-mode error") - assert.Equal(t, initialWarnings, compiler.GetWarningCount(), "sudo:true + runtime:gvisor must not produce a warning") + require.Error(t, err, "sudo:true + runtime:gvisor must still produce a strict-mode error") + assert.Contains(t, err.Error(), "sudo", "error must mention sudo") } // TestGVisorFrontmatterExtraction verifies end-to-end that a workflow with @@ -251,7 +274,6 @@ sandbox: agent: id: awf runtime: gvisor - sudo: true --- # Test gVisor Runtime @@ -275,10 +297,11 @@ sandbox: // AWF install step must also be present. assert.Contains(t, lockStr, "Install AWF binary", "compiled workflow must include AWF install step") - // containerRuntime: gvisor must appear in the AWF config JSON. - // The AWF config is embedded as escaped JSON in a printf command in the lock file. - assert.Contains(t, lockStr, `\"containerRuntime\":\"gvisor\"`, - "compiled workflow must pass containerRuntime: gvisor to AWF") + // containerRuntime: gvisor must NOT appear with the default AWF version (v0.27.29), + // which predates containerRuntime support. It will be emitted once the minimum + // version (AWFContainerRuntimeMinVersion) is released. + assert.NotContains(t, lockStr, `\"containerRuntime\":\"gvisor\"`, + "containerRuntime must not be emitted for default AWF version") // gVisor step must appear before AWF step. gvisorPos := strings.Index(lockStr, "Install gVisor") diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index ee4ae7c9216..54f421ef03a 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -122,19 +122,7 @@ func validateSandboxConfig(workflowData *WorkflowData) error { ) } - // gVisor installation requires root access (sudo install, sudo runsc install, - // sudo systemctl restart docker). It cannot be combined with sudo: false. - if agentConfig.NetworkIsolation { - return NewValidationError( - "sandbox.agent.runtime", - string(AgentRuntimeGVisor), - "gvisor requires root access for installation and cannot be combined with sandbox.agent.sudo: false", - "The gVisor install step uses sudo to install runsc and restart Docker. "+ - "Set sandbox.agent.sudo: true to allow root access, "+ - "or remove sandbox.agent.runtime: gvisor.", - ) - } - sandboxValidationLog.Print("gVisor runtime configured -- sudo access and topology checks passed") + sandboxValidationLog.Print("gVisor runtime configured -- topology check passed") } // Validate config structure if provided (deprecated - was only for SRT) diff --git a/pkg/workflow/strict_mode_sandbox_validation.go b/pkg/workflow/strict_mode_sandbox_validation.go index aafc7e24506..22ccb3407d0 100644 --- a/pkg/workflow/strict_mode_sandbox_validation.go +++ b/pkg/workflow/strict_mode_sandbox_validation.go @@ -45,9 +45,7 @@ func (c *Compiler) validateStrictSandboxCustomization(sandboxConfig *SandboxConf if agent := sandboxConfig.Agent; agent != nil { // sandbox.agent.sudo: true is deprecated regardless of strict mode. // It is an error in strict mode and a warning otherwise. - // Exception: sandbox.agent.runtime: gvisor requires root access for installation, - // so sudo: true is implicitly required and the deprecation warning is suppressed. - if agent.SudoExplicitlyEnabled && agent.Runtime != AgentRuntimeGVisor { + if agent.SudoExplicitlyEnabled { const sudoTrueMsg = "sandbox.agent.sudo: true re-enables host-access (sudo) mode. " + "The default is now sudo: false (network isolation). " + "Remove 'sudo: true' to use the secure default. " + From 5a8aabb2fb0282dc319da47162ec08cc870645b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:43:17 +0000 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20update=20ADR=20to=20reflect=20revie?= =?UTF-8?q?w=20feedback=20=E2=80=94=20gVisor=20works=20with=20sudo:false,?= =?UTF-8?q?=20no=20deprecation=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- docs/adr/44796-gvisor-runtime-for-agent-containers.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adr/44796-gvisor-runtime-for-agent-containers.md b/docs/adr/44796-gvisor-runtime-for-agent-containers.md index 05266334dc8..16d42066ac1 100644 --- a/docs/adr/44796-gvisor-runtime-for-agent-containers.md +++ b/docs/adr/44796-gvisor-runtime-for-agent-containers.md @@ -12,7 +12,7 @@ Agent containers in gh-aw run under a standard Docker runtime backed by Linux cg ### Decision -We will add `sandbox.agent.runtime: gvisor` as an optional frontmatter field. When set, the compiler (1) emits a GitHub Actions step before the AWF install step that downloads `runsc` and `containerd-shim-runsc-v1` from `storage.googleapis.com/gvisor`, installs them to `/usr/local/bin`, registers them with Docker via `sudo runsc install`, restarts Docker with `systemctl restart`, and verifies the runtime with a test container, and (2) passes `containerRuntime: "gvisor"` in the AWF stdin config JSON so AWF starts the agent container under `runsc`. Two incompatible combinations are rejected at compile time: `runtime: gvisor` + `runner.topology: arc-dind` (systemd unavailable on ARC DinD) and `runtime: gvisor` + `sudo: false` (the install step requires root). The `sudo: true` deprecation warning is suppressed when `runtime: gvisor` is set, since gVisor fundamentally requires root for installation. +We will add `sandbox.agent.runtime: gvisor` as an optional frontmatter field. When set, the compiler (1) emits a GitHub Actions step before the AWF install step that downloads `runsc` and `containerd-shim-runsc-v1` from `storage.googleapis.com/gvisor`, installs them to `/usr/local/bin`, registers them with Docker via `sudo runsc install`, restarts Docker with `systemctl restart`, and verifies the runtime with a test container, and (2) passes `containerRuntime: "gvisor"` in the AWF stdin config JSON so AWF starts the agent container under `runsc` — once the effective AWF version is at or above `AWFContainerRuntimeMinVersion` (currently a placeholder at `v0.28.0` pending the release of gh-aw-firewall#6093). One incompatible combination is rejected at compile time: `runtime: gvisor` + `runner.topology: arc-dind` (systemd unavailable on ARC DinD). The `runtime: gvisor` option is compatible with both `sudo: false` (default, network-isolation mode) and `sudo: true` — the gVisor install step uses shell-level sudo commands independently of the `sandbox.agent.sudo` setting. The existing `sudo: true` deprecation warning continues to apply regardless of `runtime`. ### Alternatives Considered @@ -32,15 +32,15 @@ Reduce the syscall surface by attaching a restrictive seccomp profile or AppArmo #### Positive - Agent containers configured with `runtime: gvisor` run under gVisor's `runsc` OCI runtime, which interposes on all system calls via a user-space kernel, substantially reducing the host kernel attack surface. -- Compile-time validation catches the two known incompatible combinations (`arc-dind`, `sudo: false`) before a workflow is submitted, giving authors a clear error message rather than a runtime failure. +- Compile-time validation catches the known incompatible combination (`arc-dind`) before a workflow is submitted, giving authors a clear error message rather than a runtime failure. - The change is fully backward-compatible: existing workflows that do not set `sandbox.agent.runtime` are unaffected and continue to run under the default Docker runtime. -- The `sudo: true` deprecation warning is suppressed when gVisor is configured, avoiding a misleading warning for a case where `sudo: true` is not just acceptable but required. +- `runtime: gvisor` is compatible with `sudo: false` (the default, network-isolation mode): the install step uses shell-level sudo commands independently of the `sandbox.agent.sudo` setting, so operators can have both gVisor kernel isolation and AWF network isolation. +- Removing the special case for `AgentRuntimeGVisor` in `validateStrictSandboxCustomization` simplifies the strict-mode validation path. #### Negative -- `runtime: gvisor` requires `sudo: true`, which disables AWF's network-isolation mode (`--network-isolation`). Operators gain kernel-level isolation but lose the network-isolation guarantee unless both are layered via other mechanisms. This is a hard trade-off imposed by gVisor's requirement for root during installation. - `runtime: gvisor` is incompatible with `runner.topology: arc-dind`, restricting which runner configurations can use this feature. - The gVisor install step adds observable setup time (binary downloads, Docker restart, verification container run) to every workflow execution that enables this option. -- Introducing a special case in `validateStrictSandboxCustomization` for `AgentRuntimeGVisor` increases the complexity of the strict-mode validation path. +- The `containerRuntime` field in the AWF config is gated behind `AWFContainerRuntimeMinVersion` (placeholder `v0.28.0`) until gh-aw-firewall#6093 is released; until then, the install step runs but AWF uses its default runtime. #### Neutral - The `systemctl restart docker` (rather than `reload`) constraint documented in the implementation will need to be preserved in future modifications to the install step: Docker's SIGHUP reload does not call `setHostGatewayIP()`, which breaks `--add-host host.docker.internal:host-gateway`. From c7725449ac78751a3d76309e924f72a733b62f4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:43:54 +0000 Subject: [PATCH 7/8] docs: set ADR deciders field Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- docs/adr/44796-gvisor-runtime-for-agent-containers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/44796-gvisor-runtime-for-agent-containers.md b/docs/adr/44796-gvisor-runtime-for-agent-containers.md index 16d42066ac1..bcb9f72c24b 100644 --- a/docs/adr/44796-gvisor-runtime-for-agent-containers.md +++ b/docs/adr/44796-gvisor-runtime-for-agent-containers.md @@ -2,7 +2,7 @@ **Date**: 2026-07-10 **Status**: Draft -**Deciders**: Unknown +**Deciders**: @lpcox --- From e7fc1d7fe1ba2170efafae0ed5191b687aab8ae4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:45:41 +0000 Subject: [PATCH 8/8] fix: pin gVisor to versioned release with SHA-512 verification Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/constants/README.md | 1 + pkg/constants/version_constants.go | 8 ++++++++ pkg/workflow/copilot_engine_installation.go | 15 +++++++++++++-- pkg/workflow/gvisor_test.go | 11 +++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/pkg/constants/README.md b/pkg/constants/README.md index 555e4935dda..0f5a2bf1db1 100644 --- a/pkg/constants/README.md +++ b/pkg/constants/README.md @@ -322,6 +322,7 @@ constants.AWFDockerHostPathPrefixMinVersion // "v0.25.43" — minimum AWF for -- constants.AWFTokenSteeringMinVersion // "v0.25.44" — minimum AWF for token steering support constants.AWFChrootConfigMinVersion // "v0.27.1" — minimum AWF for chroot.binariesSourcePath and identity.* constants.AWFContainerRuntimeMinVersion // "v0.28.0" — minimum AWF for containerRuntime in container config (gh-aw-firewall#6093; TODO: update on release) +constants.DefaultGVisorVersion // "20250623.0" — pinned gVisor release for the generated install step; bump after reviewing release notes constants.CopilotNoAskUserMinVersion // "1.0.19" — minimum Copilot CLI for --no-ask-user constants.MCPGIntegrityReactionsMinVersion // "v0.2.18" — minimum MCPG for integrity-reactions policy ``` diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index 26bee4247ed..577ceb37502 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -117,6 +117,14 @@ const AWFArcDindMinVersion Version = "v0.27.20" // emit the field for workflows pinned to any currently-available version. const AWFContainerRuntimeMinVersion Version = "v0.28.0" +// 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 +// integrity verification before the binaries are installed with root privileges. +// Bump this constant after reviewing the release notes at +// https://github.com/google/gvisor/releases. +const DefaultGVisorVersion = "20250623.0" + // CopilotNoAskUserMinVersion is the minimum Copilot CLI version that supports the --no-ask-user // flag, which enables fully autonomous agentic runs by suppressing interactive prompts. // Workflows using an older Copilot CLI version must not emit --no-ask-user or the run will fail. diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index d5ce03bef78..52732d8e744 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -336,12 +336,17 @@ func generateDockerComposeInstallStep() GitHubActionStep { // AWF invocation step so that Docker can start the agent container under runsc. // // Key implementation notes: +// - Pins to constants.DefaultGVisorVersion rather than "latest" for reproducible, +// verifiable installs. Each binary is verified against its official SHA-512 file +// before being installed with root privileges, matching the pattern used by the +// adjacent AWF installer. // - Uses uname -m directly (x86_64, aarch64) — gVisor download URLs use raw arch names. // - Restarts Docker with `systemctl restart` (NOT reload): Docker's SIGHUP reload does // not call setHostGatewayIP(), which breaks --add-host host.docker.internal:host-gateway. // - Downloads both runsc and containerd-shim-runsc-v1; the shim is required for Docker's // containerd integration. func generateGVisorInstallStep() GitHubActionStep { + version := constants.DefaultGVisorVersion return GitHubActionStep([]string{ " - name: Install gVisor (runsc)", " run: |", @@ -349,10 +354,16 @@ func generateGVisorInstallStep() GitHubActionStep { "", ` echo "::group::Install gVisor (runsc)"`, ` ARCH=$(uname -m)`, - ` URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"`, - ` echo "Downloading runsc for ${ARCH}..."`, + ` URL="https://storage.googleapis.com/gvisor/releases/release/` + version + `/${ARCH}"`, + ` echo "Downloading runsc ` + version + ` for ${ARCH}..."`, ` curl -fsSL "${URL}/runsc" -o /tmp/runsc`, + ` curl -fsSL "${URL}/runsc.sha512" -o /tmp/runsc.sha512`, + ` echo "Verifying SHA-512 for runsc..."`, + ` (cd /tmp && sha512sum -c runsc.sha512)`, ` curl -fsSL "${URL}/containerd-shim-runsc-v1" -o /tmp/containerd-shim-runsc-v1`, + ` curl -fsSL "${URL}/containerd-shim-runsc-v1.sha512" -o /tmp/containerd-shim-runsc-v1.sha512`, + ` echo "Verifying SHA-512 for containerd-shim-runsc-v1..."`, + ` (cd /tmp && sha512sum -c containerd-shim-runsc-v1.sha512)`, ` sudo install -m 755 /tmp/runsc /usr/local/bin/runsc`, ` sudo install -m 755 /tmp/containerd-shim-runsc-v1 /usr/local/bin/containerd-shim-runsc-v1`, ` runsc --version`, diff --git a/pkg/workflow/gvisor_test.go b/pkg/workflow/gvisor_test.go index 4da90f73177..2ecd4353a04 100644 --- a/pkg/workflow/gvisor_test.go +++ b/pkg/workflow/gvisor_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/github/gh-aw/pkg/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,6 +35,16 @@ func TestGenerateGVisorInstallStep(t *testing.T) { // Must use gVisor's official download URL. assert.Contains(t, content, "storage.googleapis.com/gvisor", "must use official gVisor download URL") + // Must use a pinned release (not "latest") for reproducible supply-chain-safe installs. + assert.Contains(t, content, constants.DefaultGVisorVersion, + "must use pinned gVisor release, not a mutable pointer like 'latest'") + assert.NotContains(t, content, "/latest/", "must NOT use the mutable 'latest' release path") + + // Both binaries must be integrity-verified via SHA-512 before sudo install. + assert.Contains(t, content, "runsc.sha512", "must download SHA-512 for runsc") + assert.Contains(t, content, "containerd-shim-runsc-v1.sha512", "must download SHA-512 for containerd-shim-runsc-v1") + assert.Contains(t, content, "sha512sum -c", "must verify SHA-512 checksums before installing") + // Must install binaries to system path (requires sudo). assert.Contains(t, content, "sudo install", "must install binaries with sudo") assert.Contains(t, content, "/usr/local/bin/runsc", "must install runsc to /usr/local/bin")