Skip to content
21 changes: 18 additions & 3 deletions .github/workflows/smoke-call-workflow.lock.yml

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

1 change: 0 additions & 1 deletion docs/tests/mobile-responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ test.describe('Mobile and Responsive Layout', () => {
await context.close();
});


// Regression test for https://github.com/github/gh-aw/issues/29545
// Verify the navigation dropdown is fully within the viewport when large
// user fonts cause header elements to shift on Android Chrome.
Expand Down
113 changes: 58 additions & 55 deletions pkg/workflow/threat_detection_external.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package workflow

import (
"fmt"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -66,28 +67,46 @@ func codexProxyWebsocketBaseURL(apiBase string) string {
}
}

// buildPullAWFContainersStep creates a step that pre-pulls AWF (agent workflow firewall)
// container images in the detection job. The detection engine runs inside AWF, which uses
// three containers (squid, agent, api-proxy). Pre-pulling avoids on-demand pulls at runtime.
// Only AWF images are pulled here; MCP server images are not needed for detection.
func (c *Compiler) buildPullAWFContainersStep(data *WorkflowData) []string {
// Build a minimal WorkflowData that represents the detection engine context so
// collectDockerImages returns only the AWF firewall images (no MCP tool images).
engineSetting := data.AI
if engineSetting == "" {
engineSetting = "claude"
// buildThreatDetectionWorkflowData creates the shared minimal WorkflowData used by
// detection-job helper steps so topology- and feature-dependent behavior stays in sync.
// It always initializes SandboxConfig.Agent because downstream detection helpers
// extend the agent sandbox configuration (for example, external-detector mounts).
// Callers can pass an empty engineID to inherit the detection job's default engine
// resolution from the source WorkflowData.
func buildThreatDetectionWorkflowData(data *WorkflowData, engineID string) *WorkflowData {
if engineID == "" {
engineID = data.AI
}
detectionData := &WorkflowData{
Tools: map[string]any{},
AI: engineSetting,
if engineID == "" {
engineID = "claude"
}

return &WorkflowData{
AI: engineID,
ActionCache: data.ActionCache,
Features: data.Features,
Permissions: data.Permissions,
CachedPermissions: data.CachedPermissions,
IsDetectionRun: true,
RunnerConfig: data.RunnerConfig,
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{
Type: SandboxTypeAWF,
},
},
ActionCache: data.ActionCache, // Propagate cache so container digest pins are applied
Features: data.Features, // Propagate features so cli-proxy image is included when enabled
}
}

// buildPullAWFContainersStep creates a step that pre-pulls AWF (agent workflow firewall)
// container images in the detection job. The detection engine runs inside AWF, which uses
// the firewall stack containers needed for the selected topology (squid, agent, api-proxy,
// plus build-tools on arc-dind). Pre-pulling avoids on-demand pulls at runtime. Only AWF
// images are pulled here; MCP server images are not needed for detection.
func (c *Compiler) buildPullAWFContainersStep(data *WorkflowData) []string {
// Build a minimal WorkflowData that represents the detection engine context so
// collectDockerImages returns only the AWF firewall images (no MCP tool images).
detectionData := buildThreatDetectionWorkflowData(data, "")
detectionData.Tools = map[string]any{}

images := collectDockerImages(detectionData.Tools, detectionData, c.actionMode)
if len(images) == 0 {
Expand Down Expand Up @@ -179,22 +198,11 @@ func (c *Compiler) buildInstallDetectionEngineForExternalDetectorStep(data *Work

// Build a synthetic detection WorkflowData solely to generate the engine's
// installation steps for this separate detection job context.
threatDetectionData := &WorkflowData{
Tools: map[string]any{
"bash": []any{"*"},
},
EngineConfig: &EngineConfig{ID: engineID},
AI: engineID,
Features: data.Features,
Permissions: data.Permissions,
CachedPermissions: data.CachedPermissions,
IsDetectionRun: true,
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{
Type: SandboxTypeAWF,
},
},
threatDetectionData := buildThreatDetectionWorkflowData(data, engineID)
threatDetectionData.Tools = map[string]any{
"bash": []any{"*"},
}
threatDetectionData.EngineConfig = &EngineConfig{ID: engineID}

if canReuseThreatDetectionEngineConfigForExternalDetector(data, engineID) {
ec := data.SafeOutputs.ThreatDetection.EngineConfig
Expand Down Expand Up @@ -239,6 +247,14 @@ func isAWFBinaryInstallStep(step GitHubActionStep) bool {
return false
}

func appendThreatDetectionRWMount(mounts []string) []string {
threatDetectionMount := constants.ThreatDetectionDir + ":" + constants.ThreatDetectionDir + ":rw"
if slices.Contains(mounts, threatDetectionMount) {
return mounts
}
return append(mounts, threatDetectionMount)
}

// buildExternalDetectorExecutionStep creates the AWF execution step for the external
// threat-detect binary. It runs threat-detect inside the AWF firewall sandbox with a
// read-write mount so detection_result.json can be written from inside the container
Expand All @@ -261,31 +277,18 @@ func (c *Compiler) buildExternalDetectorExecutionStep(data *WorkflowData) []stri
// Build detection WorkflowData for the external detector.
// The rw mount for ThreatDetectionDir allows the threat-detect binary to write
// detection_result.json from inside the AWF container to the host filesystem.
threatDetectionData := &WorkflowData{
Tools: map[string]any{
"bash": []any{"*"},
},
EngineConfig: &EngineConfig{ID: engineID},
AI: engineID,
Features: data.Features,
Permissions: data.Permissions,
CachedPermissions: data.CachedPermissions,
IsDetectionRun: true,
NetworkPermissions: &NetworkPermissions{
Allowed: getThreatDetectionAdditionalAllowedDomains(data),
},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{
Type: SandboxTypeAWF,
// Add a read-write mount so the threat-detect binary can write
// detection_result.json inside the container and it becomes visible
// on the host through the bind mount.
Mounts: []string{
constants.ThreatDetectionDir + ":" + constants.ThreatDetectionDir + ":rw",
},
},
},
}
threatDetectionData := buildThreatDetectionWorkflowData(data, engineID)
Comment thread
github-actions[bot] marked this conversation as resolved.
threatDetectionData.Tools = map[string]any{
"bash": []any{"*"},
}
threatDetectionData.EngineConfig = &EngineConfig{ID: engineID}
threatDetectionData.NetworkPermissions = &NetworkPermissions{
Allowed: getThreatDetectionAdditionalAllowedDomains(data),
}
// Add a read-write mount so the threat-detect binary can write
// detection_result.json inside the container and it becomes visible
// on the host through the bind mount.
threatDetectionData.SandboxConfig.Agent.Mounts = appendThreatDetectionRWMount(threatDetectionData.SandboxConfig.Agent.Mounts)

// Inherit engine config overrides from threat-detection config when set.
if canReuseThreatDetectionEngineConfigForExternalDetector(data, engineID) {
Expand Down
32 changes: 8 additions & 24 deletions pkg/workflow/threat_detection_inline_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,33 +115,17 @@ func (c *Compiler) buildDetectionEngineExecutionStep(data *WorkflowData) []strin
// bash: ["*"] allows all shell commands — AWF's network firewall is the primary
// constraint, so restricting individual bash commands inside the sandbox adds friction
// without meaningful security benefit.
// RunnerConfig is propagated from the main workflow data so that arc-dind topology
// handling (daemon-visible Copilot staging step + daemon-visible spawn path) applies
// to the detection job the same way it applies to the agent job.
// ModelMappings is propagated so the detection awf-config.json includes the alias map
// (apiProxy.models). Without it, copilot_harness.cjs cannot resolve alias model names
// (e.g. "small") to concrete ids before spawning the Copilot CLI in the detection job.
threatDetectionData := &WorkflowData{
Tools: map[string]any{
"bash": []any{"*"},
},
SafeOutputs: nil,
EngineConfig: detectionEngineConfig,
AI: engineSetting,
Features: data.Features,
Permissions: data.Permissions,
CachedPermissions: data.CachedPermissions,
IsDetectionRun: true, // Mark as detection run for phase tagging
RunnerConfig: data.RunnerConfig, // propagate runner.topology (e.g. arc-dind) to the detection job
ModelMappings: data.ModelMappings, // propagate alias map so detection awf-config.json can resolve model aliases
NetworkPermissions: &NetworkPermissions{
Allowed: getThreatDetectionAdditionalAllowedDomains(data),
},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{
Type: SandboxTypeAWF,
},
},
threatDetectionData := buildThreatDetectionWorkflowData(data, engineSetting)
threatDetectionData.Tools = map[string]any{
"bash": []any{"*"},
}
threatDetectionData.EngineConfig = detectionEngineConfig
threatDetectionData.ModelMappings = data.ModelMappings // propagate alias map so detection awf-config.json can resolve model aliases
threatDetectionData.NetworkPermissions = &NetworkPermissions{
Allowed: getThreatDetectionAdditionalAllowedDomains(data),
}

var steps []string
Expand Down
Loading
Loading