From abd3a3da9e035cedac29825f663f2798b4845519 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 6 Aug 2026 13:09:54 -0700 Subject: [PATCH 1/8] Add unified enclave MCP compiler support Compile AWF-owned script and agent enclaves through mcpg with run-scoped capability handoff, timeout derivation, network validation, schemas, tests, and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../schemas/mcp-gateway-config.schema.json | 12 + docs/src/content/docs/reference/enclaves.md | 33 +++ pkg/parser/schemas/main_workflow_schema.json | 115 ++++++++ pkg/workflow/awf_config.go | 6 + pkg/workflow/awf_helpers.go | 7 + pkg/workflow/codex_mcp.go | 2 + pkg/workflow/compiler_validators.go | 1 + pkg/workflow/enclaves.go | 272 ++++++++++++++++++ pkg/workflow/enclaves_test.go | 183 ++++++++++++ pkg/workflow/frontmatter_serialization.go | 3 + pkg/workflow/frontmatter_types.go | 1 + pkg/workflow/mcp_renderer.go | 4 + pkg/workflow/mcp_renderer_factory.go | 3 + pkg/workflow/mcp_renderer_types.go | 1 + pkg/workflow/mcp_setup_gateway.go | 22 ++ pkg/workflow/mcp_setup_generator.go | 3 + pkg/workflow/schemas/awf-config.schema.json | 130 +++++++++ .../schemas/mcp-gateway-config.schema.json | 12 + pkg/workflow/workflow_builder.go | 8 + pkg/workflow/workflow_data.go | 1 + 20 files changed, 819 insertions(+) create mode 100644 docs/src/content/docs/reference/enclaves.md create mode 100644 pkg/workflow/enclaves.go create mode 100644 pkg/workflow/enclaves_test.go diff --git a/docs/public/schemas/mcp-gateway-config.schema.json b/docs/public/schemas/mcp-gateway-config.schema.json index 806eebe0d40..9ef8960b3e2 100644 --- a/docs/public/schemas/mcp-gateway-config.schema.json +++ b/docs/public/schemas/mcp-gateway-config.schema.json @@ -163,6 +163,18 @@ }, "default": ["*"] }, + "connectTimeout": { + "type": "integer", + "description": "Per-transport timeout in seconds while connecting to an HTTP MCP upstream.", + "minimum": 1, + "default": 30 + }, + "toolTimeout": { + "type": "integer", + "description": "Per-server timeout in seconds for a tool invocation.", + "minimum": 1, + "default": 60 + }, "env": { "type": "object", "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md new file mode 100644 index 00000000000..07bad36a64d --- /dev/null +++ b/docs/src/content/docs/reference/enclaves.md @@ -0,0 +1,33 @@ +--- +title: Private repository enclaves +description: Configure unified AWF script and agent enclaves through the trusted MCP gateway. +--- + +The top-level `enclaves` field enables finite-disclosure access to approved private repositories. The compiler registers only the enabled `enclave_run_script` and `enclave_run_agent` tools on the `awf-enclave` MCP route. + +Enclaves require AWF network isolation. Configure `sandbox.agent.sudo: false` (or the `docker-sbx` runtime) so the compiler launches mcpg in bridge mode and AWF can attach it to the isolated topology. + +```yaml +enclaves: + enabled: true + private-repos: + - repo: octo-org/private-service + sensitivity: confidential + executors: + script: + enabled: true + timeout: 45 + agent: + enabled: true + model: gpt-5 + timeout: 180 + +sandbox: + agent: + id: awf + sudo: false +``` + +The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available and sets its tool timeout to the longest enabled executor timeout plus 30 seconds. + +This compiler contract depends on the unified enclave implementation from `github/gh-aw-firewall#6992`. Until that change is available in an AWF release, pinning an older AWF version will not provide the enclave server. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 58bb0bb0b17..c2bc923b904 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2791,6 +2791,121 @@ } ] }, + "enclaves": { + "type": "object", + "description": "Unified AWF-owned private-repository script and agent enclaves. Enabled executors are exposed only through the compiler-launched MCP gateway.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "private-repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "executors": { + "type": "object", + "additionalProperties": false, + "properties": { + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "network": { "const": "none", "default": "none" }, + "interpreter": { "const": "python3", "default": "python3" }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } + } + }, + "agent": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "network": { "const": "api-proxy-only", "default": "api-proxy-only" }, + "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, + "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, + "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, + "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, + "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + }, + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { "required": ["model"] } + } + } + } + }, + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { + "required": ["private-repos", "executors"], + "properties": { + "executors": { + "anyOf": [ + { + "required": ["script"], + "properties": { + "script": { + "required": ["enabled"], + "properties": { "enabled": { "const": true } } + } + } + }, + { + "required": ["agent"], + "properties": { + "agent": { + "required": ["enabled"], + "properties": { "enabled": { "const": true } } + } + } + } + ] + } + } + } + }, "runner": { "type": "object", "description": "Runner topology configuration. Tells gh-aw and AWF what kind of runner environment the workflow targets, so they can activate topology-specific behaviors automatically (split-filesystem handling, network isolation, sysroot images, tool cache redirection). The runner.topology key is the single stable contract between gh-aw and AWF for runner environment detection.", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index b28c0ceb969..eae6e0d9811 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -174,6 +174,9 @@ type AWFConfigFile struct { // cross-repository private data access. Omitted when not configured. BoundedQueries *AWFBoundedQueriesConfig `json:"boundedQueries,omitempty"` + // Enclaves configures the unified AWF-owned script and agent enclave subsystem. + Enclaves map[string]any `json:"enclaves,omitempty"` + // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` @@ -475,6 +478,9 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfig := AWFConfigFile{ Schema: buildAWFConfigSchemaURL(firewallConfig), } + if config.WorkflowData != nil { + awfConfig.Enclaves = buildAWFEnclavesConfig(config.WorkflowData.Enclaves) + } // ── Runner section ────────────────────────────────────────────────────── if topology := getRunnerTopology(config.WorkflowData); topology != "" { diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 7ae853fdff1..b20b363a2df 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -1002,6 +1002,13 @@ func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames // The runner-owned gateway forwards them only for HTTP MCP github-oidc authentication. addUnique("ACTIONS_ID_TOKEN_REQUEST_URL") addUnique("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if enclavesEnabled(workflowData) { + addUnique(enclaveMCPCapabilityEnv) + addUnique(enclaveMCPGatewayContainerEnv) + addUnique(enclaveMCPGatewayEndpointEnv) + addUnique(enclaveMCPGatewayIdentityEnv) + addUnique(enclaveMCPReadinessTimeoutEnv) + } // Explicitly excluded env vars from the frontmatter excluded-env field. // These are always excluded regardless of their value content. diff --git a/pkg/workflow/codex_mcp.go b/pkg/workflow/codex_mcp.go index af12a4d605d..29ff772e8c3 100644 --- a/pkg/workflow/codex_mcp.go +++ b/pkg/workflow/codex_mcp.go @@ -76,6 +76,8 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an if hasMCPScripts { renderer.RenderMCPScriptsMCP(&mcpConfigContent, workflowData.MCPScripts, workflowData) } + case enclaveMCPServerName: + writeEnclaveMCPTOML(&mcpConfigContent, workflowData) default: // Handle custom MCP tools using shared helper (with adapter for isLast parameter) HandleCustomMCPToolInSwitch(&mcpConfigContent, toolName, expandedTools, false, func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index dafd3c86208..c8b1d46ba3b 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -197,6 +197,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar {logMessage: "Validating OTLP workload identity configuration", validateFn: func() error { return validateOTLPWorkloadIdentity(workflowData) }}, {logMessage: "Validating default AI credits pricing values", validateFn: func() error { return validateDefaultAiCreditsPricing(workflowData) }}, {logMessage: "Validating tools.github.bounded-queries configuration", validateFn: func() error { return validateBoundedQueriesConfig(workflowData) }}, + {logMessage: "Validating enclaves configuration", validateFn: func() error { return validateEnclavesConfig(workflowData) }}, } // This validation is intentionally outside the table below because strict mode // turns the same validation result into either an error or a warning. diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go new file mode 100644 index 00000000000..042885a574d --- /dev/null +++ b/pkg/workflow/enclaves.go @@ -0,0 +1,272 @@ +package workflow + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +const ( + enclaveMCPServerName = "awf-enclave" + enclaveMCPUpstreamURL = "http://awf-enclave-mcp:8080/mcp" + enclaveMCPCapabilityEnv = "AWF_ENCLAVE_MCP_CAPABILITY" + enclaveMCPGatewayContainerEnv = "AWF_ENCLAVE_MCP_GATEWAY_CONTAINER" + enclaveMCPGatewayEndpointEnv = "AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT" + enclaveMCPGatewayIdentityEnv = "AWF_ENCLAVE_MCP_GATEWAY_IDENTITY" + enclaveMCPReadinessTimeoutEnv = "AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS" + enclaveMCPGatewayRunLabel = "com.github.gh-aw.mcpg.run" + enclaveMCPGatewayContainer = "awmg-mcpg" + enclaveMCPConnectTimeout = 120 + enclaveMCPReadinessTimeoutMS = 120000 + defaultScriptEnclaveTimeout = 30 + defaultAgentEnclaveTimeout = 120 +) + +var enclaveRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) + +// EnclavesConfig configures AWF-owned, finite-disclosure private repository executors. +type EnclavesConfig struct { + Enabled bool `json:"enabled,omitempty"` + PrivateRepos []*EnclavePrivateRepo `json:"private-repos,omitempty"` + Executors *EnclaveExecutorsConfig `json:"executors,omitempty"` +} + +type EnclavePrivateRepo struct { + Repo string `json:"repo"` + Sensitivity string `json:"sensitivity"` +} + +type EnclaveExecutorsConfig struct { + Script *ScriptEnclaveExecutorConfig `json:"script,omitempty"` + Agent *AgentEnclaveExecutorConfig `json:"agent,omitempty"` +} + +type ScriptEnclaveExecutorConfig struct { + Enabled bool `json:"enabled,omitempty"` + Runtime string `json:"runtime,omitempty"` + Image string `json:"image,omitempty"` + Network string `json:"network,omitempty"` + Interpreter string `json:"interpreter,omitempty"` + Timeout int `json:"timeout,omitempty"` + MemoryLimit string `json:"memory-limit,omitempty"` + CPULimit string `json:"cpu-limit,omitempty"` + PIDsLimit int `json:"pids-limit,omitempty"` + TmpfsLimit string `json:"tmpfs-limit,omitempty"` + MaxOutputBytes int `json:"max-output-bytes,omitempty"` + MaxScriptBytes int `json:"max-script-bytes,omitempty"` + MaxInvocations int `json:"max-invocations,omitempty"` +} + +type AgentEnclaveExecutorConfig struct { + Enabled bool `json:"enabled,omitempty"` + Runtime string `json:"runtime,omitempty"` + Image string `json:"image,omitempty"` + Network string `json:"network,omitempty"` + Engine string `json:"engine,omitempty"` + Profile string `json:"profile,omitempty"` + Model string `json:"model,omitempty"` + Timeout int `json:"timeout,omitempty"` + MemoryLimit string `json:"memory-limit,omitempty"` + CPULimit string `json:"cpu-limit,omitempty"` + PIDsLimit int `json:"pids-limit,omitempty"` + TmpfsLimit string `json:"tmpfs-limit,omitempty"` + MaxOutputBytes int `json:"max-output-bytes,omitempty"` + MaxTaskBytes int `json:"max-task-bytes,omitempty"` + MaxInvocations int `json:"max-invocations,omitempty"` + MaxModelRequests int `json:"max-model-requests,omitempty"` + MaxModelTokens int `json:"max-model-tokens,omitempty"` +} + +func enclavesEnabled(workflowData *WorkflowData) bool { + return workflowData != nil && workflowData.Enclaves != nil && workflowData.Enclaves.Enabled +} + +func enabledEnclaveTools(workflowData *WorkflowData) []string { + if !enclavesEnabled(workflowData) || workflowData.Enclaves.Executors == nil { + return nil + } + var tools []string + if script := workflowData.Enclaves.Executors.Script; script != nil && script.Enabled { + tools = append(tools, "enclave_run_script") + } + if agent := workflowData.Enclaves.Executors.Agent; agent != nil && agent.Enabled { + tools = append(tools, "enclave_run_agent") + } + return tools +} + +func enclaveToolTimeout(workflowData *WorkflowData) int { + maxTimeout := 0 + if !enclavesEnabled(workflowData) || workflowData.Enclaves.Executors == nil { + return 0 + } + if script := workflowData.Enclaves.Executors.Script; script != nil && script.Enabled { + timeout := script.Timeout + if timeout == 0 { + timeout = defaultScriptEnclaveTimeout + } + maxTimeout = max(maxTimeout, timeout) + } + if agent := workflowData.Enclaves.Executors.Agent; agent != nil && agent.Enabled { + timeout := agent.Timeout + if timeout == 0 { + timeout = defaultAgentEnclaveTimeout + } + maxTimeout = max(maxTimeout, timeout) + } + return maxTimeout + 30 +} + +func validateEnclavesConfig(workflowData *WorkflowData) error { + if !enclavesEnabled(workflowData) { + return nil + } + if !isAWFNetworkIsolationEnabled(workflowData) { + return errors.New("enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") + } + if workflowData.ParsedTools != nil && + workflowData.ParsedTools.GitHub != nil && + workflowData.ParsedTools.GitHub.BoundedQueries != nil { + return errors.New("enclaves cannot be combined with tools.github.bounded-queries") + } + config := workflowData.Enclaves + if len(config.PrivateRepos) == 0 { + return errors.New("enclaves.private-repos must contain at least one repository when enclaves is enabled") + } + seen := make(map[string]struct{}, len(config.PrivateRepos)) + for i, repo := range config.PrivateRepos { + if repo == nil { + return fmt.Errorf("enclaves.private-repos[%d] must be an object", i) + } + parts := strings.SplitN(repo.Repo, "/", 2) + if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { + return fmt.Errorf("enclaves.private-repos[%d].repo must be a bare owner/repository slug", i) + } + + key := strings.ToLower(repo.Repo) + if _, ok := seen[key]; ok { + return fmt.Errorf("enclaves.private-repos contains duplicate repository %q", repo.Repo) + } + seen[key] = struct{}{} + switch repo.Sensitivity { + case "public", "internal", "confidential", "sealed": + default: + return fmt.Errorf("enclaves.private-repos[%d].sensitivity must be public, internal, confidential, or sealed", i) + } + } + if len(enabledEnclaveTools(workflowData)) == 0 { + return errors.New("enclaves.executors must enable at least one of script or agent") + } + if agent := config.Executors.Agent; agent != nil && agent.Enabled && agent.Model == "" { + return errors.New("enclaves.executors.agent.model is required when the agent executor is enabled") + } + return nil +} + +func buildAWFEnclavesConfig(config *EnclavesConfig) map[string]any { + if config == nil || !config.Enabled { + return nil + } + result := map[string]any{"enabled": true} + privateRepos := make([]map[string]any, 0, len(config.PrivateRepos)) + for _, repo := range config.PrivateRepos { + privateRepos = append(privateRepos, map[string]any{ + "repo": repo.Repo, "sensitivity": repo.Sensitivity, + }) + } + result["privateRepos"] = privateRepos + executors := make(map[string]any) + if config.Executors != nil { + if script := config.Executors.Script; script != nil { + values := map[string]any{"enabled": script.Enabled} + addEnclaveString(values, "runtime", script.Runtime) + addEnclaveString(values, "image", script.Image) + addEnclaveString(values, "network", script.Network) + addEnclaveString(values, "interpreter", script.Interpreter) + addEnclaveInt(values, "timeout", script.Timeout) + addEnclaveString(values, "memoryLimit", script.MemoryLimit) + addEnclaveString(values, "cpuLimit", script.CPULimit) + addEnclaveInt(values, "pidsLimit", script.PIDsLimit) + addEnclaveString(values, "tmpfsLimit", script.TmpfsLimit) + addEnclaveInt(values, "maxOutputBytes", script.MaxOutputBytes) + addEnclaveInt(values, "maxScriptBytes", script.MaxScriptBytes) + addEnclaveInt(values, "maxInvocations", script.MaxInvocations) + executors["script"] = values + } + if agent := config.Executors.Agent; agent != nil { + values := map[string]any{"enabled": agent.Enabled} + addEnclaveString(values, "runtime", agent.Runtime) + addEnclaveString(values, "image", agent.Image) + addEnclaveString(values, "network", agent.Network) + addEnclaveString(values, "engine", agent.Engine) + addEnclaveString(values, "profile", agent.Profile) + addEnclaveString(values, "model", agent.Model) + addEnclaveInt(values, "timeout", agent.Timeout) + addEnclaveString(values, "memoryLimit", agent.MemoryLimit) + addEnclaveString(values, "cpuLimit", agent.CPULimit) + addEnclaveInt(values, "pidsLimit", agent.PIDsLimit) + addEnclaveString(values, "tmpfsLimit", agent.TmpfsLimit) + addEnclaveInt(values, "maxOutputBytes", agent.MaxOutputBytes) + addEnclaveInt(values, "maxTaskBytes", agent.MaxTaskBytes) + addEnclaveInt(values, "maxInvocations", agent.MaxInvocations) + addEnclaveInt(values, "maxModelRequests", agent.MaxModelRequests) + addEnclaveInt(values, "maxModelTokens", agent.MaxModelTokens) + executors["agent"] = values + } + } + result["executors"] = executors + return result +} + +func addEnclaveString(values map[string]any, key, value string) { + if value != "" { + values[key] = value + } +} + +func addEnclaveInt(values map[string]any, key string, value int) { + if value != 0 { + values[key] = value + } +} + +func writeEnclaveMCPJSON(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) { + fmt.Fprintf(yaml, " %q: {\n", enclaveMCPServerName) + yaml.WriteString(" \"type\": \"http\",\n") + fmt.Fprintf(yaml, " \"url\": %q,\n", enclaveMCPUpstreamURL) + fmt.Fprintf(yaml, " \"headers\": {\"Authorization\": \"Bearer \\${%s}\"},\n", enclaveMCPCapabilityEnv) + fmt.Fprintf(yaml, " \"tools\": [") + for i, tool := range enabledEnclaveTools(workflowData) { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "%q", tool) + } + yaml.WriteString("],\n") + fmt.Fprintf(yaml, " \"connectTimeout\": %d,\n", enclaveMCPConnectTimeout) + fmt.Fprintf(yaml, " \"toolTimeout\": %d\n", enclaveToolTimeout(workflowData)) + yaml.WriteString(" }") + if !isLast { + yaml.WriteString(",") + } + yaml.WriteString("\n") +} + +func writeEnclaveMCPTOML(yaml *strings.Builder, workflowData *WorkflowData) { + yaml.WriteString(" \n") + fmt.Fprintf(yaml, " [mcp_servers.%s]\n", enclaveMCPServerName) + yaml.WriteString(" type = \"http\"\n") + fmt.Fprintf(yaml, " url = %q\n", enclaveMCPUpstreamURL) + fmt.Fprintf(yaml, " headers = { Authorization = \"Bearer $%s\" }\n", enclaveMCPCapabilityEnv) + fmt.Fprintf(yaml, " tools = [") + for i, tool := range enabledEnclaveTools(workflowData) { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "%q", tool) + } + yaml.WriteString("]\n") + fmt.Fprintf(yaml, " connectTimeout = %d\n", enclaveMCPConnectTimeout) + fmt.Fprintf(yaml, " toolTimeout = %d\n", enclaveToolTimeout(workflowData)) +} diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go new file mode 100644 index 00000000000..5993ca0e6c9 --- /dev/null +++ b/pkg/workflow/enclaves_test.go @@ -0,0 +1,183 @@ +package workflow + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func enclaveWorkflowData(script, agent bool, scriptTimeout, agentTimeout int) *WorkflowData { + data := &WorkflowData{ + Tools: map[string]any{}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + NetworkIsolation: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Enclaves: &EnclavesConfig{ + Enabled: true, + PrivateRepos: []*EnclavePrivateRepo{{ + Repo: "octo-org/private-service", Sensitivity: "confidential", + }}, + Executors: &EnclaveExecutorsConfig{}, + }, + } + if script { + data.Enclaves.Executors.Script = &ScriptEnclaveExecutorConfig{ + Enabled: true, Timeout: scriptTimeout, + } + } + if agent { + data.Enclaves.Executors.Agent = &AgentEnclaveExecutorConfig{ + Enabled: true, Model: "gpt-5", Timeout: agentTimeout, + } + } + return data +} + +func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { + tests := []struct { + name string + script, agent bool + scriptTime, agentTime int + wantTools []string + wantTimeout int + }{ + {"script only defaults", true, false, 0, 0, []string{"enclave_run_script"}, 60}, + {"agent only defaults", false, true, 0, 0, []string{"enclave_run_agent"}, 150}, + {"both use maximum", true, true, 200, 90, []string{"enclave_run_script", "enclave_run_agent"}, 230}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := enclaveWorkflowData(tt.script, tt.agent, tt.scriptTime, tt.agentTime) + assert.Equal(t, tt.wantTools, enabledEnclaveTools(data)) + assert.Equal(t, tt.wantTimeout, enclaveToolTimeout(data)) + assert.Contains(t, collectMCPTools(data), enclaveMCPServerName) + }) + } + + disabled := enclaveWorkflowData(true, true, 30, 120) + disabled.Enclaves.Enabled = false + assert.Empty(t, enabledEnclaveTools(disabled)) + assert.NotContains(t, collectMCPTools(disabled), enclaveMCPServerName) +} + +func TestValidateEnclavesRequiresNetworkIsolation(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.SandboxConfig.Agent.NetworkIsolation = false + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires AWF network isolation") +} + +func TestValidateEnclavesRejectsBoundedQueries(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.ParsedTools = &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{}, + }, + } + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined with tools.github.bounded-queries") +} + +func TestBuildAWFConfigJSONEnclaves(t *testing.T) { + data := enclaveWorkflowData(true, true, 45, 180) + configJSON, err := BuildAWFConfigJSON(AWFCommandConfig{ + EngineName: "copilot", WorkflowData: data, + }) + require.NoError(t, err) + + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(configJSON), &config)) + enclaves := config["enclaves"].(map[string]any) + executors := enclaves["executors"].(map[string]any) + assert.InDelta(t, 45, executors["script"].(map[string]any)["timeout"], 0) + assert.Equal(t, "gpt-5", executors["agent"].(map[string]any)["model"]) + assert.Equal(t, []any{"awmg-mcpg"}, config["network"].(map[string]any)["topologyAttach"]) + assert.NotContains(t, configJSON, "boundedQueries") + assert.NotContains(t, configJSON, "boundedAgents") +} + +func TestGenerateEnclaveGatewayContract(t *testing.T) { + data := enclaveWorkflowData(true, true, 45, 180) + ensureDefaultMCPGatewayConfig(data) + var output strings.Builder + require.NoError(t, generateMCPGatewaySetup( + &output, data.Tools, []string{enclaveMCPServerName}, NewCopilotEngine(), data, false, nil, + )) + generated := output.String() + + assert.Contains(t, generated, `"awf-enclave": {`) + assert.Contains(t, generated, `"url": "http://awf-enclave-mcp:8080/mcp"`) + assert.Contains(t, generated, `"connectTimeout": 120`) + assert.Contains(t, generated, `"toolTimeout": 210`) + assert.Contains(t, generated, `"tools": ["enclave_run_script", "enclave_run_agent"]`) + assert.Contains(t, generated, `Bearer \${AWF_ENCLAVE_MCP_CAPABILITY}`) + assert.Contains(t, generated, `openssl rand -hex 32`) + assert.Contains(t, generated, `::add-mask::${AWF_ENCLAVE_MCP_CAPABILITY}`) + assert.Contains(t, generated, `--network bridge`) + assert.Contains(t, generated, `--label com.github.gh-aw.mcpg.run=`) + assert.Contains(t, generated, `${AWF_ENCLAVE_MCP_GATEWAY_IDENTITY}`) + assert.Contains(t, generated, `-e AWF_ENCLAVE_MCP_CAPABILITY`) + assert.Contains(t, generated, `AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT="http://localhost:${MCP_GATEWAY_PORT}/mcp/awf-enclave"`) + assert.NotRegexp(t, `AWF_ENCLAVE_MCP_CAPABILITY=[0-9a-f]{64}`, generated) + + excluded := ComputeAWFExcludeEnvVarNames(data, nil) + assert.Contains(t, excluded, enclaveMCPCapabilityEnv) + assert.Contains(t, excluded, enclaveMCPGatewayIdentityEnv) +} + +func TestCompileEnclaveStartupOrdering(t *testing.T) { + tmp := t.TempDir() + workflowPath := filepath.Join(tmp, "enclave.md") + content := `--- +on: workflow_dispatch +strict: false +network: defaults +engine: copilot +sandbox: + agent: + id: awf + sudo: false + version: latest +enclaves: + enabled: true + private-repos: + - repo: octo-org/private-service + sensitivity: confidential + executors: + script: + enabled: true + timeout: 45 +--- + +Use the enclave script executor. +` + require.NoError(t, os.WriteFile(workflowPath, []byte(content), 0o600)) + compiler := NewCompiler() + compiler.SetSkipValidation(true) + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + lockBytes, err := os.ReadFile(stringutil.MarkdownToLockFile(workflowPath)) + require.NoError(t, err) + lock := string(lockBytes) + + gateway := strings.Index(lock, "- name: Start MCP Gateway") + awf := strings.Index(lock, "awf --config") + require.Greater(t, gateway, -1) + require.Greater(t, awf, -1) + assert.Less(t, gateway, awf) + assert.NotContains(t, lock, "Start Enclave MCP") + assert.NotContains(t, lock, "start_enclave") +} diff --git a/pkg/workflow/frontmatter_serialization.go b/pkg/workflow/frontmatter_serialization.go index 7996eb3a7c3..d70053d00f2 100644 --- a/pkg/workflow/frontmatter_serialization.go +++ b/pkg/workflow/frontmatter_serialization.go @@ -120,6 +120,9 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { // Convert MCPScriptsConfig to map - would need a ToMap method result["mcp-scripts"] = fc.MCPScripts } + if fc.Enclaves != nil { + result["enclaves"] = fc.Enclaves + } // Event and trigger configuration if fc.On != nil { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index e0fde496cc1..817f5902c8b 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -344,6 +344,7 @@ type FrontmatterConfig struct { Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` + Enclaves *EnclavesConfig `json:"enclaves,omitempty"` PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) // Event and trigger configuration diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 49f7f5cd4e0..b0128d603d5 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -175,6 +175,10 @@ func RenderJSONMCPConfig( if options.Renderers.RenderMCPScripts != nil { options.Renderers.RenderMCPScripts(&configBuilder, workflowData.MCPScripts, isLast) } + case enclaveMCPServerName: + if options.Renderers.RenderEnclave != nil { + options.Renderers.RenderEnclave(&configBuilder, workflowData, isLast) + } default: // Handle custom MCP tools using shared helper HandleCustomMCPToolInSwitch(&configBuilder, toolName, tools, isLast, options.Renderers.RenderCustomMCPConfig) diff --git a/pkg/workflow/mcp_renderer_factory.go b/pkg/workflow/mcp_renderer_factory.go index 8984f9ef87a..ed96bf217ad 100644 --- a/pkg/workflow/mcp_renderer_factory.go +++ b/pkg/workflow/mcp_renderer_factory.go @@ -192,6 +192,9 @@ func buildStandardJSONMCPRenderers( RenderMCPScripts: func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) { createRenderer(isLast).RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, + RenderEnclave: func(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) { + writeEnclaveMCPJSON(yaml, workflowData, isLast) + }, RenderCustomMCPConfig: renderCustom, } } diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go index 625db122e52..a56848bc8f3 100644 --- a/pkg/workflow/mcp_renderer_types.go +++ b/pkg/workflow/mcp_renderer_types.go @@ -43,6 +43,7 @@ type MCPToolRenderers struct { RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool) RenderSafeOutputs func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) RenderMCPScripts func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) + RenderEnclave func(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) RenderCustomMCPConfig RenderCustomMCPToolConfigHandler } diff --git a/pkg/workflow/mcp_setup_gateway.go b/pkg/workflow/mcp_setup_gateway.go index 781573f7cfd..d920c40b7d7 100644 --- a/pkg/workflow/mcp_setup_gateway.go +++ b/pkg/workflow/mcp_setup_gateway.go @@ -192,6 +192,22 @@ func writeMCPGatewayExports(yaml *strings.Builder, opts writeMCPGatewayExportsOp yaml.WriteString(" export MCP_GATEWAY_PAYLOAD_PATH_PREFIX=\"" + payloadPathPrefix + "\"\n") } yaml.WriteString(" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD=\"" + strconv.Itoa(payloadSizeThreshold) + "\"\n") + if enclavesEnabled(workflowData) { + yaml.WriteString(" AWF_ENCLAVE_MCP_CAPABILITY=$(openssl rand -hex 32)\n") + yaml.WriteString(" echo \"::add-mask::${AWF_ENCLAVE_MCP_CAPABILITY}\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_CAPABILITY\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_IDENTITY=\"gh-aw-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_CONTAINER=\"awmg-mcpg\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT=\"http://localhost:${MCP_GATEWAY_PORT}/mcp/awf-enclave\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS=\"120000\"\n") + yaml.WriteString(" {\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_CAPABILITY \"$AWF_ENCLAVE_MCP_CAPABILITY\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_IDENTITY \"$AWF_ENCLAVE_MCP_GATEWAY_IDENTITY\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_CONTAINER \"$AWF_ENCLAVE_MCP_GATEWAY_CONTAINER\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT \"$AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS \"$AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS\"\n") + yaml.WriteString(" } >> \"$GITHUB_ENV\"\n") + } yaml.WriteString(" export DEBUG=\"*\"\n") yaml.WriteString(" \n") yaml.WriteString(" export GH_AW_ENGINE=\"" + engine.GetID() + "\"\n") @@ -275,6 +291,9 @@ func buildMCPGatewayContainerCommand(opts buildMCPGatewayContainerCommandOptions containerCmd.WriteString(" --network host") } containerCmd.WriteString(" --name awmg-mcpg") + if enclavesEnabled(workflowData) { + containerCmd.WriteString(" --label " + enclaveMCPGatewayRunLabel + "=${AWF_ENCLAVE_MCP_GATEWAY_IDENTITY}") + } if !isAWFNetworkIsolationEnabled(workflowData) { containerCmd.WriteString(" --add-host host.docker.internal:127.0.0.1") } else if shouldRewriteLocalhostToDocker(workflowData) { @@ -365,6 +384,9 @@ func appendMCPGatewayBaseEnvFlags(containerCmd *strings.Builder, payloadPathPref } func appendMCPGatewayConditionalEnvFlags(containerCmd *strings.Builder, workflowData *WorkflowData, engine CodingAgentEngine, hasGitHub bool, githubTool map[string]any, tools map[string]any) { + if enclavesEnabled(workflowData) { + containerCmd.WriteString(" -e " + enclaveMCPCapabilityEnv) + } if hasGitHub && getGitHubType(githubTool) == GitHubMCPModeRemote && engine.GetID() == "copilot" { containerCmd.WriteString(" -e GITHUB_PERSONAL_ACCESS_TOKEN") } diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go index 08f0e7faff9..31c81563f71 100644 --- a/pkg/workflow/mcp_setup_generator.go +++ b/pkg/workflow/mcp_setup_generator.go @@ -168,6 +168,9 @@ func collectMCPTools(workflowData *WorkflowData) []string { if IsMCPScriptsEnabled(workflowData.MCPScripts) { mcpTools = append(mcpTools, "mcp-scripts") } + if enclavesEnabled(workflowData) { + mcpTools = append(mcpTools, enclaveMCPServerName) + } return mcpTools } diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 8348151fb76..699031bbff9 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -767,6 +767,136 @@ } } }, + "enclaves": { + "type": "object", + "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes enabled executors only through an AWF-owned MCP server and the compiler-launched trusted mcpg gateway.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable the unified enclave subsystem. Cannot be enabled with boundedQueries or boundedAgents." + }, + "privateRepos": { + "type": "array", + "minItems": 1, + "description": "Private repositories shared by every configured enclave executor. Sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { "type": "string", "enum": ["public", "internal", "confidential", "sealed"] } + } + } + }, + "executors": { + "type": "object", + "additionalProperties": false, + "description": "Trusted executor definitions. Images, runtimes, networks, models, timeouts, and resources are AWF configuration and must never be accepted from an invocation request.", + "properties": { + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned script-executor image." + }, + "network": { "const": "none", "default": "none" }, + "interpreter": { "const": "python3", "default": "python3" }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, + "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpuLimit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pidsLimit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfsLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "maxOutputBytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "maxScriptBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, + "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } + } + }, + "agent": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned engine image." + }, + "network": { "const": "api-proxy-only", "default": "api-proxy-only" }, + "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, + "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, + "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpuLimit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pidsLimit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfsLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "maxOutputBytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "maxTaskBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, + "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, + "maxModelRequests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, + "maxModelTokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + }, + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { "required": ["model"] } + } + } + } + }, + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { + "required": ["privateRepos", "executors"], + "properties": { + "executors": { + "anyOf": [ + { + "required": ["script"], + "properties": { + "script": { + "required": ["enabled"], + "properties": { "enabled": { "const": true } } + } + } + }, + { + "required": ["agent"], + "properties": { + "agent": { + "required": ["enabled"], + "properties": { "enabled": { "const": true } } + } + } + } + ] + } + } + } + }, "boundedQueries": { "type": "object", "description": "Bounded-query sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `bounded-query` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", diff --git a/pkg/workflow/schemas/mcp-gateway-config.schema.json b/pkg/workflow/schemas/mcp-gateway-config.schema.json index d08d8f91a98..6edc303d139 100644 --- a/pkg/workflow/schemas/mcp-gateway-config.schema.json +++ b/pkg/workflow/schemas/mcp-gateway-config.schema.json @@ -145,6 +145,18 @@ }, "default": ["*"] }, + "connectTimeout": { + "type": "integer", + "description": "Per-transport timeout in seconds while connecting to an HTTP MCP upstream.", + "minimum": 1, + "default": 30 + }, + "toolTimeout": { + "type": "integer", + "description": "Per-server timeout in seconds for a tool invocation.", + "minimum": 1, + "default": 60 + }, "env": { "type": "object", "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 2dcc5ccc556..6ad1b238315 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -72,6 +72,7 @@ func (c *Compiler) buildInitialWorkflowData( NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), RunnerConfig: extractRunnerConfig(result.Frontmatter), + Enclaves: extractEnclavesConfig(toolsResult.parsedFrontmatter), NeedsTextOutput: toolsResult.needsTextOutput, ToolsTimeout: toolsResult.toolsTimeout, ToolsStartupTimeout: toolsResult.toolsStartupTimeout, @@ -214,6 +215,13 @@ func (c *Compiler) buildInitialWorkflowData( return workflowData } +func extractEnclavesConfig(frontmatter *FrontmatterConfig) *EnclavesConfig { + if frontmatter == nil { + return nil + } + return frontmatter.Enclaves +} + func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) map[string]LSPServerConfig { if parsedFrontmatter != nil && len(parsedFrontmatter.LSP) > 0 { return parsedFrontmatter.LSP diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index 58252fea3a3..ab4ff82ee51 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -134,6 +134,7 @@ type WorkflowData struct { SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes SafeOutputsInputEnvVars map[string]string // GH_AW_INPUT_* env vars referenced by safe-outputs config; populated during MCP setup generation so renderers can forward them to the nested container MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools + Enclaves *EnclavesConfig // AWF-owned private repository enclave executors LabelNames []string // label names that must match for pull_request_target labeled events (on.labels) Roles []string // permission levels required to trigger workflow Bots []string // allow list of bot identifiers that can trigger workflow From 9550613410421f7137be18096fc17bf12b40f21c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:46:50 +0000 Subject: [PATCH 2/8] Merge main into lpcox-compile-enclave-mcp, resolve awf_helpers.go conflicts Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- ...aily-agent-of-the-day-blog-writer.lock.yml | 2 +- .../daily-agent-of-the-day-blog-writer.md | 69 +- .github/workflows/daily-regulatory.lock.yml | 147 +- .github/workflows/daily-regulatory.md | 2 + .github/workflows/deep-report.lock.yml | 144 +- .github/workflows/deep-report.md | 2 + .../deployment-incident-monitor.lock.yml | 147 +- .../workflows/deployment-incident-monitor.md | 2 + .../impeccable-skills-reviewer.lock.yml | 4 +- .../mattpocock-skills-reviewer.lock.yml | 2 +- .../pr-code-quality-reviewer.lock.yml | 240 +-- .github/workflows/pr-code-quality-reviewer.md | 18 +- .../workflows/shared/pr-diff-data-fetch.md | 6 +- actions/setup/js/notify_comment_error.cjs | 26 +- .../setup/js/notify_comment_error.test.cjs | 26 +- ...-split-awf-helpers-into-focused-modules.md | 49 + pkg/workflow/awf_arc_dind.go | 109 ++ pkg/workflow/awf_arc_dind_test.go | 239 +++ pkg/workflow/awf_command_builder.go | 644 +++++++ pkg/workflow/awf_command_builder_test.go | 679 +++++++ pkg/workflow/awf_env.go | 198 ++ pkg/workflow/awf_env_test.go | 324 ++++ pkg/workflow/awf_feature_flags.go | 85 + pkg/workflow/awf_feature_flags_test.go | 363 ++++ pkg/workflow/awf_helpers.go | 1027 +---------- pkg/workflow/awf_helpers_test.go | 1603 +---------------- 26 files changed, 3050 insertions(+), 3107 deletions(-) create mode 100644 docs/adr/51154-split-awf-helpers-into-focused-modules.md create mode 100644 pkg/workflow/awf_arc_dind.go create mode 100644 pkg/workflow/awf_arc_dind_test.go create mode 100644 pkg/workflow/awf_command_builder.go create mode 100644 pkg/workflow/awf_command_builder_test.go create mode 100644 pkg/workflow/awf_env.go create mode 100644 pkg/workflow/awf_env_test.go create mode 100644 pkg/workflow/awf_feature_flags.go create mode 100644 pkg/workflow/awf_feature_flags_test.go diff --git a/.github/workflows/daily-agent-of-the-day-blog-writer.lock.yml b/.github/workflows/daily-agent-of-the-day-blog-writer.lock.yml index af8947a58ec..1ab66c54ad1 100644 --- a/.github/workflows/daily-agent-of-the-day-blog-writer.lock.yml +++ b/.github/workflows/daily-agent-of-the-day-blog-writer.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"698d31709d8149a3329d94dd5843903edf563160af401697e49b4dc175df997d","body_hash":"90ac679daa04d7b6fd717a7ae6b4242d6c4eceb4bbf1de36036671ff4f7a741d","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"698d31709d8149a3329d94dd5843903edf563160af401697e49b4dc175df997d","body_hash":"483aa6956be37c88ae5731cdfcc3efd8c8b16ca2e4f2a6ec97afce17b6ef19b7","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44","digest":"sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/daily-agent-of-the-day-blog-writer.md b/.github/workflows/daily-agent-of-the-day-blog-writer.md index 2f4b5baabdc..9b189c769e6 100644 --- a/.github/workflows/daily-agent-of-the-day-blog-writer.md +++ b/.github/workflows/daily-agent-of-the-day-blog-writer.md @@ -96,10 +96,8 @@ You write one short blog entry per weekday for the `gh-aw` docs blog spotlightin - Keep writing vivid and varied — avoid repetitive or robotic voice. - Keep the post to a **maximum 5-minute read** (target 450–900 words). - Stay corporate appropriate and compliant with Microsoft/GitHub policies. -- Use sub-agents: - - one to generate a blogger persona, - - one to write the story in GitHub blog style using that persona, - - one to optimize SEO metadata (`seoDescription`, `linkedPostText`). +- Generate blogger persona, story draft, and SEO metadata in this same agent session. +- Do not call sub-agent/task tools for this workflow. - Use `agentic-workflows` `logs` and `audit` results as live evidence and include links to referenced issues/PRs. - If a chart image is available, include it in the post. - The `create_pull_request` patch must contain only text changes under `docs/src/content/docs/**`; never include binary assets in the PR patch — use `upload-asset` for those. @@ -138,19 +136,19 @@ If no remote image URL is available but `docs/public/blog-combined.png` exists, Do not stage the PNG with `git add` and do not include any binary files in the PR. -### 4) Generate persona and draft content through sub-agents +### 4) Generate persona and draft content -1. Call `persona-generator` to produce a fresh blogger persona. -2. Call `story-writer` with: - - persona output, +1. Create a fresh blogger persona. +2. Write the story in GitHub blog style using: + - persona, - chosen workflow, - extracted run evidence, - issue/PR links, - optional chart URL. -3. Call `seo-optimizer` to generate: +3. Generate: - `seoDescription` (max 160 chars, SERP-friendly), - `linkedPostText` (short, clickable link text for post cards/social snippets). - - If `seoDescription` is over 160 characters, rewrite it before continuing. +4. If `seoDescription` is over 160 characters, rewrite it before continuing. ### 5) Create blog post file @@ -212,54 +210,3 @@ Never end with plain text only and no safe-output call. - No policy-unsafe or non-corporate language. - Keep it concise, energetic, and developer-friendly. - Vary rhythm and phrasing between runs. - -#### agent: `persona-generator` ---- -description: Generates a rotating, policy-safe blogger persona for daily workflow storytelling -model: mai-code ---- -Produce a short persona profile for a GitHub blog voice. - -Output format: -- Name: -- Tone: -- Signature style traits (3 bullets): -- Avoid list (2 bullets to avoid robotic/repetitive writing): - -Constraints: -- Corporate appropriate. -- Professional and friendly. -- Distinct from generic AI assistant voice. -- Do not include slang that could violate workplace norms. - -#### agent: `story-writer` ---- -description: Writes a lively, evidence-grounded Agent of the Day story in GitHub blog style -model: large ---- -Write a concise blog post body in GitHub blog style using the provided persona and evidence. - -Requirements: -- 450–900 words max. -- Vary sentence length and paragraph rhythm. -- Use concrete details from provided logs/audit evidence only. -- Include issue/PR links naturally in the narrative. -- Stay policy-safe and corporate appropriate. -- Keep it useful and readable for developers. - -Return only markdown body content (no frontmatter). - -#### agent: `seo-optimizer` ---- -description: Produces SEO metadata for Astro blog cards and link previews -model: mai-code ---- -Generate: -1) `seoDescription`: <= 160 characters, search-optimized, accurate. -2) `linkedPostText`: <= 80 characters, compelling but professional. - -Rules: -- Must align with the real post content. -- No hypey clickbait, no unverifiable claims. -- Maintain GitHub/Microsoft corporate tone. -- Hard limit: never return `seoDescription` longer than 160 characters. diff --git a/.github/workflows/daily-regulatory.lock.yml b/.github/workflows/daily-regulatory.lock.yml index 098ec72e30d..6ed4462efa4 100644 --- a/.github/workflows/daily-regulatory.lock.yml +++ b/.github/workflows/daily-regulatory.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6ae145c70cfad2503de9c5e4f49c0f57c2752523aac2846a19a653e0c7057a48","body_hash":"a4c8035aba813a54d7c615f23f1c340c652db21bce844c8286523faa5bc68f43","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"61bff293be68026ec398f8d60e9e2f7c3750a4fd8a66722c1c3b5df24d8e0e01","body_hash":"a4c8035aba813a54d7c615f23f1c340c652db21bce844c8286523faa5bc68f43","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -156,6 +156,7 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "⚖️" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -1936,6 +1937,7 @@ jobs: WORKFLOW_DESCRIPTION: "Daily regulatory workflow that monitors and cross-checks other daily report agents' outputs for data consistency and anomalies" HAS_PATCH: ${{ needs.agent.outputs.has_patch }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + GH_AW_DETECTION_SKIP_PROMPT_SUMMARY: "true" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -1949,31 +1951,55 @@ jobs: touch /tmp/gh-aw/threat-detection/detection.log rm -f /tmp/gh-aw/step-summary.md touch /tmp/gh-aw/step-summary.md - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - package-manager-cache: false + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com GH_AW_COMPILED_VERSION: dev - - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - - name: Execute GitHub Copilot CLI + - name: Install threat-detect binary if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/install_threat_detect_binary.sh" latest + - name: Execute threat detection with AWF id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: detection + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: dev + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + WORKFLOW_NAME: "Daily Regulatory Report Generator" + WORKFLOW_DESCRIPTION: "Daily regulatory workflow that monitors and cross-checks other daily report agents' outputs for data consistency and anomalies" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT - mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" - export XDG_CONFIG_HOME="$HOME" GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 @@ -1986,13 +2012,9 @@ jobs: fi chmod 755 "$GH_AW_COPILOT_BIN" - touch /tmp/gh-aw/agent-step-summary.md - GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) - export GH_AW_NODE_BIN - export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" @@ -2012,41 +2034,23 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs ${RUNNER_TEMP}/gh-aw/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md - AWF_REFLECT_ENABLED: 1 - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: detection - GH_AW_LLM_PROVIDER: github - GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} - GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: dev - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - RUNNER_TEMP: ${{ runner.temp }} - TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - - name: Echo detection step summary + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw/threat-detection:/tmp/gh-aw/threat-detection:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && threat-detect --engine copilot --output /tmp/gh-aw/threat-detection/detection_result.json --step-summary /tmp/gh-aw/step-summary.md /tmp/gh-aw/threat-detection' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + - name: Upload threat detection artifact if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: | + /tmp/gh-aw/threat-detection/detection_result.json + /tmp/gh-aw/threat-detection/detection.log + /tmp/gh-aw/step-summary.md + if-no-files-found: ignore + - name: Append detection step summary + if: always() run: | if [ -s /tmp/gh-aw/step-summary.md ]; then - cat /tmp/gh-aw/step-summary.md + cat /tmp/gh-aw/step-summary.md >> "$GITHUB_STEP_SUMMARY" fi - name: Parse threat detection token usage for step summary id: parse_detection_token_usage @@ -2061,45 +2065,16 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Parse and conclude threat detection + - name: Conclude threat detection id: detection_conclusion if: always() continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" - with: - script: | - try { - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - } catch (loadErr) { - const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; - const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; - const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); - core.error(msg); - core.setOutput('reason', 'parse_error'); - if (continueOnError && !detectionExecutionFailed) { - core.warning('\u26A0\uFE0F ' + msg); - core.setOutput('conclusion', 'warning'); - core.setOutput('success', 'false'); - } else { - core.setOutput('conclusion', 'failure'); - core.setOutput('success', 'false'); - core.setFailed(msg); - } - } + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/conclude_threat_detection.sh" /tmp/gh-aw/threat-detection/detection_result.json evals: needs: diff --git a/.github/workflows/daily-regulatory.md b/.github/workflows/daily-regulatory.md index eb86c104c76..f86b79736e6 100644 --- a/.github/workflows/daily-regulatory.md +++ b/.github/workflows/daily-regulatory.md @@ -38,6 +38,8 @@ imports: - shared/otlp.md +features: + gh-aw-detection: true evals: - id: report_outputs_cross_checked question: Did the agent cross-check other daily report agents' outputs for consistency and anomalies? diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index d078a16bc85..bc6a1b1edd6 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"07de217275fdbef3dd17870362951d1ef62b640299dae201bc6353e313d029a7","body_hash":"ad29b5fb3fdcf78812b02abfbd9443f027b0efbf0f091a5c614333c031d8a0f3","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.223"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"60648ee12c66aaa3670aea5c58260e9adc84317eac8cc03b6d9c05b17fdbb3c5","body_hash":"ad29b5fb3fdcf78812b02abfbd9443f027b0efbf0f091a5c614333c031d8a0f3","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.223"}} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44","digest":"sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"},{"image":"node:lts-alpine","digest":"sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43","pinned_image":"node:lts-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -167,6 +167,7 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🔬" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -2285,6 +2286,7 @@ jobs: WORKFLOW_DESCRIPTION: "Intelligence gathering agent that continuously reviews and aggregates information from agent-generated reports in discussions" HAS_PATCH: ${{ needs.agent.outputs.has_patch }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + GH_AW_DETECTION_SKIP_PROMPT_SUMMARY: "true" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -2298,66 +2300,24 @@ jobs: touch /tmp/gh-aw/threat-detection/detection.log rm -f /tmp/gh-aw/step-summary.md touch /tmp/gh-aw/step-summary.md + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false - - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Install Claude Code CLI run: npm install -g @anthropic-ai/claude-code@2.1.223 - - name: Execute Claude Code CLI + - name: Install threat-detect binary if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true - id: detection_agentic_execution - # Allowed tools (sorted): - # - Bash - # - BashOutput - # - Edit(/tmp/*) - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - MultiEdit(/tmp/*) - # - NotebookRead - # - Read - # - Read(/tmp/*) - # - Task - # - TodoWrite - # - Write(/tmp/*) - timeout-minutes: 20 run: | - set -o pipefail - printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - touch /tmp/gh-aw/agent-step-summary.md - (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json - export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" - GH_AW_DOCKER_HOST="" - if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then - GH_AW_DOCKER_HOST="${DOCKER_HOST}" - fi - if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then - _GH_AW_CHROOT_JSON=$(jq -c --arg src "${RUNNER_TEMP}/gh-aw" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "${RUNNER_TEMP}/gh-aw/home" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } - printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - fi - GH_AW_TOOL_CACHE_MOUNT="" - GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" - if [ -d "$GH_AW_TOOL_CACHE" ]; then - if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then - GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" - fi - fi - # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --tty --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env ANTHROPIC_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/claude_harness.cjs claude --print --no-chrome --allowed-tools '\''Bash,BashOutput,Edit(/tmp/*),ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit(/tmp/*),NotebookRead,Read,Read(/tmp/*),Task,TodoWrite,Write(/tmp/*)'\'' --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode acceptEdits --output-format stream-json --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + bash "${RUNNER_TEMP}/gh-aw/actions/install_threat_detect_binary.sh" latest + - name: Execute threat detection with AWF + id: detection_agentic_execution + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true env: - GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_MODEL: detection BASH_DEFAULT_TIMEOUT_MS: 60000 @@ -2372,6 +2332,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_VERSION: dev GITHUB_AW: true + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -2381,12 +2342,52 @@ jobs: MCP_TOOL_TIMEOUT: 60000 RUNNER_TEMP: ${{ runner.temp }} TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - - name: Echo detection step summary + WORKFLOW_NAME: "Deep Report" + WORKFLOW_DESCRIPTION: "Intelligence gathering agent that continuously reviews and aggregates information from agent-generated reports in discussions" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"defaultAiCreditsPricing\":{\"input\":5,\"output\":25},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + _GH_AW_CHROOT_JSON=$(jq -c --arg src "${RUNNER_TEMP}/gh-aw" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "${RUNNER_TEMP}/gh-aw/home" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env ANTHROPIC_API_KEY --mount /tmp/gh-aw/threat-detection:/tmp/gh-aw/threat-detection:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && threat-detect --engine claude --output /tmp/gh-aw/threat-detection/detection_result.json --step-summary /tmp/gh-aw/step-summary.md /tmp/gh-aw/threat-detection' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + - name: Upload threat detection artifact if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: | + /tmp/gh-aw/threat-detection/detection_result.json + /tmp/gh-aw/threat-detection/detection.log + /tmp/gh-aw/step-summary.md + if-no-files-found: ignore + - name: Append detection step summary + if: always() run: | if [ -s /tmp/gh-aw/step-summary.md ]; then - cat /tmp/gh-aw/step-summary.md + cat /tmp/gh-aw/step-summary.md >> "$GITHUB_STEP_SUMMARY" fi - name: Parse threat detection token usage for step summary id: parse_detection_token_usage @@ -2401,45 +2402,16 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Parse and conclude threat detection + - name: Conclude threat detection id: detection_conclusion if: always() continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" - with: - script: | - try { - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - } catch (loadErr) { - const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; - const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; - const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); - core.error(msg); - core.setOutput('reason', 'parse_error'); - if (continueOnError && !detectionExecutionFailed) { - core.warning('\u26A0\uFE0F ' + msg); - core.setOutput('conclusion', 'warning'); - core.setOutput('success', 'false'); - } else { - core.setOutput('conclusion', 'failure'); - core.setOutput('success', 'false'); - core.setFailed(msg); - } - } + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/conclude_threat_detection.sh" /tmp/gh-aw/threat-detection/detection_result.json evals: needs: diff --git a/.github/workflows/deep-report.md b/.github/workflows/deep-report.md index 59562cd0f6b..20da0a15616 100644 --- a/.github/workflows/deep-report.md +++ b/.github/workflows/deep-report.md @@ -50,6 +50,8 @@ network: - python - node +features: + gh-aw-detection: true safe-outputs: upload-artifact: retention-days: 30 diff --git a/.github/workflows/deployment-incident-monitor.lock.yml b/.github/workflows/deployment-incident-monitor.lock.yml index 928f3dce905..e4e739fe27b 100644 --- a/.github/workflows/deployment-incident-monitor.lock.yml +++ b/.github/workflows/deployment-incident-monitor.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"23f1e80da17c972e14f93a3b2344b900890f75e900015c49d92e1795c095686e","body_hash":"450e87e2b475b83f6905fdfad5ed2949a633b4b81aa5017bb11d06b35e96318f","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a604ca7c4754a72412d7d3bc8be3db59f6578e36055868ff116fa2b562113534","body_hash":"450e87e2b475b83f6905fdfad5ed2949a633b4b81aa5017bb11d06b35e96318f","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44","digest":"sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -157,6 +157,7 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🚨" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -1436,6 +1437,7 @@ jobs: WORKFLOW_DESCRIPTION: "Monitors deployment failures and automatically creates deduplicated incident issues with root cause analysis." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + GH_AW_DETECTION_SKIP_PROMPT_SUMMARY: "true" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -1449,31 +1451,55 @@ jobs: touch /tmp/gh-aw/threat-detection/detection.log rm -f /tmp/gh-aw/step-summary.md touch /tmp/gh-aw/step-summary.md - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - package-manager-cache: false + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com GH_AW_COMPILED_VERSION: dev - - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - - name: Execute GitHub Copilot CLI + - name: Install threat-detect binary if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/install_threat_detect_binary.sh" latest + - name: Execute threat detection with AWF id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: detection + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: dev + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + WORKFLOW_NAME: "Deployment Incident Monitor" + WORKFLOW_DESCRIPTION: "Monitors deployment failures and automatically creates deduplicated incident issues with root cause analysis." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT - mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" - export XDG_CONFIG_HOME="$HOME" GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 @@ -1486,13 +1512,9 @@ jobs: fi chmod 755 "$GH_AW_COPILOT_BIN" - touch /tmp/gh-aw/agent-step-summary.md - GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) - export GH_AW_NODE_BIN - export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" @@ -1512,41 +1534,23 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs ${RUNNER_TEMP}/gh-aw/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md - AWF_REFLECT_ENABLED: 1 - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: detection - GH_AW_LLM_PROVIDER: github - GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} - GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: dev - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - RUNNER_TEMP: ${{ runner.temp }} - TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - - name: Echo detection step summary + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw/threat-detection:/tmp/gh-aw/threat-detection:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && threat-detect --engine copilot --output /tmp/gh-aw/threat-detection/detection_result.json --step-summary /tmp/gh-aw/step-summary.md /tmp/gh-aw/threat-detection' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + - name: Upload threat detection artifact if: always() && steps.detection_guard.outputs.run_detection == 'true' - continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: | + /tmp/gh-aw/threat-detection/detection_result.json + /tmp/gh-aw/threat-detection/detection.log + /tmp/gh-aw/step-summary.md + if-no-files-found: ignore + - name: Append detection step summary + if: always() run: | if [ -s /tmp/gh-aw/step-summary.md ]; then - cat /tmp/gh-aw/step-summary.md + cat /tmp/gh-aw/step-summary.md >> "$GITHUB_STEP_SUMMARY" fi - name: Parse threat detection token usage for step summary id: parse_detection_token_usage @@ -1561,45 +1565,16 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Parse and conclude threat detection + - name: Conclude threat detection id: detection_conclusion if: always() continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" - with: - script: | - try { - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - } catch (loadErr) { - const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; - const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; - const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); - core.error(msg); - core.setOutput('reason', 'parse_error'); - if (continueOnError && !detectionExecutionFailed) { - core.warning('\u26A0\uFE0F ' + msg); - core.setOutput('conclusion', 'warning'); - core.setOutput('success', 'false'); - } else { - core.setOutput('conclusion', 'failure'); - core.setOutput('success', 'false'); - core.setFailed(msg); - } - } + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/conclude_threat_detection.sh" /tmp/gh-aw/threat-detection/detection_result.json evals: needs: diff --git a/.github/workflows/deployment-incident-monitor.md b/.github/workflows/deployment-incident-monitor.md index 857fe74adf9..e8bc0dd08a1 100644 --- a/.github/workflows/deployment-incident-monitor.md +++ b/.github/workflows/deployment-incident-monitor.md @@ -18,6 +18,8 @@ imports: - shared/mcp-pagination.md - shared/reporting.md - shared/otlp.md +features: + gh-aw-detection: true tools: cli-proxy: true github: diff --git a/.github/workflows/impeccable-skills-reviewer.lock.yml b/.github/workflows/impeccable-skills-reviewer.lock.yml index ff3cba3162d..f8810b0c7aa 100644 --- a/.github/workflows/impeccable-skills-reviewer.lock.yml +++ b/.github/workflows/impeccable-skills-reviewer.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4ce7be7a910e4829c4d4f8b5d093ce90a72e3c738632ce5d60f25873752d95f4","body_hash":"91fe3dd50fa3a08fe527348c2ec09df80537e597e0716ba489551822d51bb468","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6","engine_versions":{"copilot":"1.0.78"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"3b3d52bb61064f9caaa586a9b296b3c0b6e97cd3a41ae6244614d3aa2062fe38","body_hash":"be8c6ebc57d2313cd75471622b16bb3475e4e25394a638ed407ada1b7591c699","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6","engine_versions":{"copilot":"1.0.78"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44","digest":"sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}],"has_pull_request":true} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -600,7 +600,7 @@ jobs: - env: EXPR_GITHUB_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - PR_DIFF_MAX_LINES: "3000" + PR_DIFF_MAX_LINES: "2000" PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} name: Pre-fetch PR diff and review comments diff --git a/.github/workflows/mattpocock-skills-reviewer.lock.yml b/.github/workflows/mattpocock-skills-reviewer.lock.yml index efefd50741a..2d7662490d9 100644 --- a/.github/workflows/mattpocock-skills-reviewer.lock.yml +++ b/.github/workflows/mattpocock-skills-reviewer.lock.yml @@ -748,7 +748,7 @@ jobs: - env: EXPR_GITHUB_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - PR_DIFF_MAX_LINES: "3000" + PR_DIFF_MAX_LINES: "2000" PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} name: Pre-fetch PR diff and review comments diff --git a/.github/workflows/pr-code-quality-reviewer.lock.yml b/.github/workflows/pr-code-quality-reviewer.lock.yml index 09a4b48f406..cd734d86dab 100644 --- a/.github/workflows/pr-code-quality-reviewer.lock.yml +++ b/.github/workflows/pr-code-quality-reviewer.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1915423b7590fd2377cb225f67fe7d9de87e69bc461aca7e160c1470fa845af1","body_hash":"d66144ce2443a9def8e7c4286113d8fb5c2fbac95fe62f367ef8cb668b9527c9","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"bf910477f3d76fdfce798d390ed239323ff0a5a209a22458f1b21ad549f5e1aa","body_hash":"592310d327fce3f7b7cafeafdb907a3cc6299e32f0b57ba39d487e29fb17a8be","strict":true,"agent_id":"pi","engine_versions":{"pi":"0.83.0"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44","digest":"sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}],"has_pull_request":true} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -91,7 +91,7 @@ run-name: "PR Code Quality Reviewer" env: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.GH_AW_OTEL_SENTRY_ENDPOINT }} OTEL_SERVICE_NAME: gh-aw.pr-code-quality-reviewer - OTEL_RESOURCE_ATTRIBUTES: 'gh-aw.workflow.name=PR%20Code%20Quality%20Reviewer,gh-aw.repository=${{ github.repository }},gh-aw.run.id=${{ github.run_id }},github.run_id=${{ github.run_id }},gh-aw.engine.id=copilot' + OTEL_RESOURCE_ATTRIBUTES: 'gh-aw.workflow.name=PR%20Code%20Quality%20Reviewer,gh-aw.repository=${{ github.repository }},gh-aw.run.id=${{ github.run_id }},github.run_id=${{ github.run_id }},gh-aw.engine.id=pi' OTEL_EXPORTER_OTLP_HEADERS: x-sentry-auth=${{ secrets.GH_AW_OTEL_SENTRY_AUTHORIZATION }} GH_AW_OTLP_ALL_HEADERS: x-sentry-auth=${{ secrets.GH_AW_OTEL_SENTRY_AUTHORIZATION }},Authorization=${{ secrets.GH_AW_OTEL_GRAFANA_AUTHORIZATION }} GH_AW_OTLP_ENDPOINTS: '[{"url":"${{ secrets.GH_AW_OTEL_SENTRY_ENDPOINT }}","headers":"x-sentry-auth=${{ secrets.GH_AW_OTEL_SENTRY_AUTHORIZATION }}"},{"url":"${{ secrets.GH_AW_OTEL_GRAFANA_ENDPOINT }}","headers":"Authorization=${{ secrets.GH_AW_OTEL_GRAFANA_AUTHORIZATION }}"}]' @@ -155,21 +155,21 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Mask OTLP telemetry headers run: bash "${RUNNER_TEMP}/gh-aw/actions/mask_otlp_headers.sh" - name: Generate agentic run info id: generate_aw_info env: - GH_AW_INFO_ENGINE_ID: "copilot" - GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" + GH_AW_INFO_ENGINE_ID: "pi" + GH_AW_INFO_ENGINE_NAME: "Pi" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_CUSTOM || 'agent' }} + GH_AW_INFO_VERSION: "0.83.0" + GH_AW_INFO_AGENT_VERSION: "0.83.0" GH_AW_INFO_WORKFLOW_NAME: "PR Code Quality Reviewer" - GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_EXPERIMENTAL: "true" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["*.grafana.net","*.sentry.io","defaults","go"]' @@ -266,8 +266,8 @@ jobs: fetch-depth: 1 - name: Save agent config folders for base branch restoration env: - GH_AW_AGENT_FOLDERS: ".agents .github" - GH_AW_AGENT_FILES: "AGENTS.md" + GH_AW_AGENT_FOLDERS: ".agents .github .pi" + GH_AW_AGENT_FILES: "AGENTS.md PI.md" run: | # poutine:ignore untrusted_checkout_exec bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" @@ -287,7 +287,7 @@ jobs: id: sanitized uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -389,7 +389,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_ID: "pi" GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_EXPR_799BE623: ${{ github.event.issue.number || github.event.pull_request.number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} @@ -474,8 +474,8 @@ jobs: /tmp/gh-aw/aw-prompts/prompt-import-tree.json /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/base - /tmp/gh-aw/.github/agents - /tmp/gh-aw/.github/skills + /tmp/gh-aw/.pi/agents + /tmp/gh-aw/.pi/skills if-no-files-found: ignore retention-days: 1 @@ -498,7 +498,6 @@ jobs: GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} GH_AW_WORKFLOW_ID_SANITIZED: prcodequalityreviewer outputs: - agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} aic: ${{ steps.parse-mcp-gateway.outputs.aic }} ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} @@ -507,15 +506,7 @@ jobs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} - http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} - inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} - invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} - max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} - mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} - missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} - missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} model: ${{ needs.activation.outputs.model }} - model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} @@ -542,9 +533,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Set runtime paths id: set-runtime-paths run: | @@ -614,15 +605,15 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" - env: - GH_HOST: github.com - GH_AW_COMPILED_VERSION: dev + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - - name: Install GitHub Copilot SDK (Node.js) - run: cd "${GITHUB_WORKSPACE}" && npm install --ignore-scripts --no-save @github/copilot-sdk@1.0.8 + - name: Install Pi CLI + run: npm install --ignore-scripts -g @earendil-works/pi-coding-agent@0.83.0 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) @@ -645,22 +636,22 @@ jobs: - name: Restore agent config folders from base branch if: steps.checkout-pr.outcome == 'success' env: - GH_AW_AGENT_FOLDERS: ".agents .github" - GH_AW_AGENT_FILES: "AGENTS.md" + GH_AW_AGENT_FOLDERS: ".agents .github .pi" + GH_AW_AGENT_FILES: "AGENTS.md PI.md" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" - name: Restore inline sub-agents from activation artifact env: - GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_DIR: ".pi/agents" GH_AW_SUB_AGENT_EXT: ".agent.md" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" - name: Restore inline skills from activation artifact env: - GH_AW_SKILL_DIR: ".github/skills" + GH_AW_SKILL_DIR: ".pi/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - env: EXPR_GITHUB_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - PR_DIFF_MAX_LINES: "3000" + PR_DIFF_MAX_LINES: "2000" PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} name: Pre-fetch PR diff and review comments @@ -858,20 +849,18 @@ jobs: export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - export GH_AW_ENGINE="copilot" + export GH_AW_ENGINE="pi" export GH_AW_MCP_CLI_SERVERS='["safeoutputs"]' MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e GITHUB_AW_OTEL_TRACE_ID -e GITHUB_AW_OTEL_PARENT_SPAN_ID -e OTEL_EXPORTER_OTLP_HEADERS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' - mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_b69663a6a6cd97a9_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_ffce2de719dc3130_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "safeoutputs": { - "type": "stdio", "container": "ghcr.io/github/gh-aw-node", "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], "args": ["-w", "\${GITHUB_WORKSPACE}"], @@ -917,7 +906,7 @@ jobs: } } } - GH_AW_MCP_CONFIG_b69663a6a6cd97a9_EOF + GH_AW_MCP_CONFIG_ffce2de719dc3130_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -954,37 +943,18 @@ jobs: CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.4.8' run: | bash "${RUNNER_TEMP}/gh-aw/actions/start_cli_proxy.sh" - - name: Execute GitHub Copilot CLI + - name: Execute Pi CLI id: agentic_execution - # Copilot CLI tool arguments (sorted): timeout-minutes: 15 run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT - mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" - export XDG_CONFIG_HOME="$HOME" - export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" - GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" - if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then - echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 - exit 127 - fi - GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" - mkdir -p "${RUNNER_TEMP}/gh-aw/bin" - if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then - cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" - fi - chmod 755 "$GH_AW_COPILOT_BIN" - touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN - export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/agent-stdio.log) - GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.grafana.net\",\"*.sentry.io\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"github.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"pkg.go.dev\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.grafana.net\",\"*.sentry.io\",\"api.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"github.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"pkg.go.dev\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" @@ -1002,62 +972,37 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull --difc-proxy-host awmg-cli-proxy:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_WORKSPACE_NODE_MODULES="${GITHUB_WORKSPACE:-$PWD}/node_modules"; if [ -d "$GH_AW_WORKSPACE_NODE_MODULES" ]; then export NODE_PATH="${GH_AW_WORKSPACE_NODE_MODULES}${NODE_PATH:+:${NODE_PATH}}"; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_sdk_driver.cjs" ${RUNNER_TEMP}/gh-aw/bin/copilot' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull --difc-proxy-host awmg-cli-proxy:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && cd "${GITHUB_WORKSPACE}" && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi --print --mode json --no-session --extension "${RUNNER_TEMP}/gh-aw/actions/pi_provider.cjs" --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs" 2>&1 | tee /tmp/gh-aw/pi-streaming.jsonl' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ github.token }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - COPILOT_SDK_URI: http://127.0.0.1:3002 - GH_AW_COPILOT_SDK_DRIVER: 1 - GH_AW_COPILOT_SDK_SERVER_ARGS: '["--headless","--no-auto-update","--port","3002","--add-dir","/tmp/gh-aw/","--log-level","all","--log-dir","/tmp/gh-aw/sandbox/agent/logs/","--disable-builtin-mcps","--no-ask-user","--allow-all-tools","--add-dir","/tmp/gh-aw/cache-memory/","--allow-all-paths"]' - GH_AW_LLM_PROVIDER: github - GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} - GH_AW_MAX_TOOL_DENIALS: 3 GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_TIMEOUT_MINUTES: 15 GH_AW_VERSION: dev GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || github.token }} - GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true - GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] + PI_OFFLINE: 1 RUNNER_TEMP: ${{ runner.temp }} - S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - name: Stop CLI Proxy if: always() continue-on-error: true run: bash "${RUNNER_TEMP}/gh-aw/actions/stop_cli_proxy.sh" - - name: Detect agent errors - if: always() - id: detect-agent-errors - continue-on-error: true - run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" - name: Configure Git credentials env: GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_TOKEN: ${{ github.token }} run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" - name: Stop MCP Gateway if: always() continue-on-error: true @@ -1097,7 +1042,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_COMMANDS: "[\"review\"]" @@ -1111,13 +1056,13 @@ jobs: if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/pi-streaming.jsonl GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_pi_log.cjs'); await main(); - name: Parse MCP Gateway logs for step summary if: always() @@ -1196,7 +1141,7 @@ jobs: name: agent path: | /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/pi-streaming.jsonl /tmp/gh-aw/redacted-urls.log /tmp/gh-aw/mcp-logs/ /tmp/gh-aw/proxy-logs/ @@ -1268,9 +1213,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1444,7 +1389,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "pr-code-quality-reviewer" GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "12" - GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_ID: "pi" GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} @@ -1453,15 +1398,6 @@ jobs: GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} GH_AW_EVALS_AIC: ${{ needs.evals.outputs.aic }} GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} - GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} - GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} - GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} - GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} - GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} - GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} - GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} @@ -1559,9 +1495,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1794,9 +1730,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1846,47 +1782,24 @@ jobs: with: node-version: '24' package-manager-cache: false - - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" - env: - GH_HOST: github.com - GH_AW_COMPILED_VERSION: dev - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - - name: Install GitHub Copilot SDK (Node.js) - run: cd "${GITHUB_WORKSPACE}" && npm install --ignore-scripts --no-save @github/copilot-sdk@1.0.8 - - name: Execute GitHub Copilot CLI + - name: Install Pi CLI + run: npm install --ignore-scripts -g @earendil-works/pi-coding-agent@0.83.0 + - name: Execute Pi CLI if: always() continue-on-error: true id: evals_agentic_execution - # Copilot CLI tool arguments (sorted): timeout-minutes: 20 run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT - mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" - export XDG_CONFIG_HOME="$HOME" - GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" - if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then - echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 - exit 127 - fi - GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" - mkdir -p "${RUNNER_TEMP}/gh-aw/bin" - if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then - cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" - fi - chmod 755 "$GH_AW_COPILOT_BIN" - touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN - export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/evals/evals.log) - GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_EVALS_MAX_AI_CREDITS || '400' }}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.44/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.44,squid=sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627,agent=sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4,api-proxy=sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7,cli-proxy=sha256:c064d15974f7c933ec7d3f7b4038f4fd203547b3154bdc821afd379144887eff\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" @@ -1904,39 +1817,26 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_WORKSPACE_NODE_MODULES="${GITHUB_WORKSPACE:-$PWD}/node_modules"; if [ -d "$GH_AW_WORKSPACE_NODE_MODULES" ]; then export NODE_PATH="${GH_AW_WORKSPACE_NODE_MODULES}${NODE_PATH:+:${NODE_PATH}}"; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_sdk_driver.cjs" ${RUNNER_TEMP}/gh-aw/bin/copilot' 2>&1 | tee -a /tmp/gh-aw/evals/evals.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && cd "${GITHUB_WORKSPACE}" && mkdir -p /tmp/gh-aw/pi-agent-dir && printf '\''%s\n'\'' '\''{"providers":{"aw-gateway":{"api":"openai-completions","apiKey":"COPILOT_GITHUB_TOKEN","baseUrl":"http://api-proxy:10002","models":[{"id":"evals"}]}}}'\'' > /tmp/gh-aw/pi-agent-dir/models.json && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi --print --mode json --no-session --model aw-gateway/evals --extension "${RUNNER_TEMP}/gh-aw/actions/pi_provider.cjs" --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs" 2>&1 | tee /tmp/gh-aw/pi-streaming.jsonl' 2>&1 | tee -a /tmp/gh-aw/evals/evals.log env: AWF_REFLECT_ENABLED: 1 - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ github.token }} - COPILOT_MODEL: evals - COPILOT_SDK_URI: http://127.0.0.1:3002 - GH_AW_COPILOT_SDK_DRIVER: 1 - GH_AW_COPILOT_SDK_SERVER_ARGS: '["--headless","--no-auto-update","--port","3002","--add-dir","/tmp/gh-aw/","--log-level","all","--log-dir","/tmp/gh-aw/sandbox/agent/logs/","--disable-builtin-mcps","--no-ask-user","--allow-all-tools"]' - GH_AW_LLM_PROVIDER: github - GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_EVALS_MAX_AI_CREDITS || '400' }} - GH_AW_MAX_TOOL_DENIALS: 3 GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} GH_AW_PHASE: evals + GH_AW_PI_MODEL: evals GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_TIMEOUT_MINUTES: 20 GH_AW_VERSION: dev - GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true - GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] + PI_CODING_AGENT_DIR: /tmp/gh-aw/pi-agent-dir + PI_OFFLINE: 1 RUNNER_TEMP: ${{ runner.temp }} - S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - name: Parse MCP Gateway logs for step summary if: always() @@ -2025,9 +1925,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Check command position id: check_command_position uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -2083,9 +1983,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -2153,7 +2053,7 @@ jobs: GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} - GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_ID: "pi" GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} GH_AW_HEAD_SHA: ${{ github.event.pull_request.head.sha }} GH_AW_PROJECT_UTC: "-08:00" @@ -2194,9 +2094,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Mask OTLP telemetry headers run: bash "${RUNNER_TEMP}/gh-aw/actions/mask_otlp_headers.sh" - name: Download agent output artifact @@ -2228,7 +2128,7 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,go.dev,golang.org,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,storage.googleapis.com,sum.golang.org,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_check_run\":{\"max\":1},\"create_pull_request_review_comment\":{\"max\":10,\"side\":\"RIGHT\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{},\"submit_pull_request_review\":{\"allowed_events\":[\"COMMENT\",\"REQUEST_CHANGES\"],\"max\":1}}" @@ -2282,9 +2182,9 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Code Quality Reviewer" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-code-quality-reviewer.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "0.83.0" GH_AW_INFO_AWF_VERSION: "v0.27.44" - GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_ID: "pi" - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/pr-code-quality-reviewer.md b/.github/workflows/pr-code-quality-reviewer.md index 29349af0913..17317c44e81 100644 --- a/.github/workflows/pr-code-quality-reviewer.md +++ b/.github/workflows/pr-code-quality-reviewer.md @@ -17,9 +17,7 @@ on: name: review events: [pull_request_comment, pull_request_review_comment] engine: - id: copilot - copilot-sdk: true -max-tool-denials: 3 + id: pi permissions: contents: read issues: read @@ -89,7 +87,7 @@ You are a highly critical code reviewer. Your mission is to aggressively find co ### Step 1: Load Pre-Fetched PR Data and Launch Sub-Agent The PR diff and metadata have already been pre-fetched and are available as local files: -- **PR diff** (capped at 3000 lines, lock/generated/dist/build files excluded): `/tmp/gh-aw/agent/pr-diff.patch` +- **PR diff** (capped at 2000 lines, lock/generated/dist/build files excluded): `/tmp/gh-aw/agent/pr-diff.patch` - **PR metadata** (files list, additions, deletions): `/tmp/gh-aw/agent/pr-meta.json` In **one parallel turn**, read those three files: @@ -135,7 +133,7 @@ You may use compact pseudo-language/encoding during private reasoning (examples: ### Step 4: Write Review Comments -For each significant issue, create a `create-pull-request-review-comment` with the file path and line number. Each comment: one visible sentence stating the issue and its impact, then a `
` block with explanation, fix snippet, and rationale. +For each significant issue, create a `create-pull-request-review-comment` with the file path and line number. Each comment: one visible sentence stating the issue and its impact, then a `
💡 …` block with explanation, fix snippet, and rationale. **Prioritization** (use your 10-comment budget aggressively): 1. Correctness, concurrency, and security-adjacent bugs (highest priority, up to 6 comments) @@ -148,6 +146,7 @@ For each significant issue, create a `create-pull-request-review-comment` with t - Issues that linters already catch automatically - Personal style preferences without a clear rationale - Code that is outside the diff (unchanged lines) +- Empty compliments, generic "looks good" notes, or friendliness padding ### Step 5: Submit the Overall Review @@ -161,23 +160,16 @@ Use `REQUEST_CHANGES` when any of the following are true: - Any issue can cause data loss, auth bypass, panic/crash, or broken CI behavior. - Sub-agent output is invalid and your second pass still finds at least one clearly actionable correctness/security/performance issue. -Use `COMMENT` when all findings are non-blocking. Keep the overall review body concise and focused on blocking themes. +Use `COMMENT` when all findings are non-blocking. Keep the overall review body concise and focused on blocking themes. Use h3 (###) or lower for any headers, and structure the body as verdict + one-line summary (always visible) → themes/highlights (in `
`). ## Guidelines -### Review Formatting - -- Use h3 (###) or lower for all headers in your review output to maintain proper document hierarchy. -- Apply **progressive disclosure** in every comment: keep the immediately visible text to one brief sentence, then wrap detailed analysis and code suggestions in `
💡 …` blocks. -- Overall review body structure: verdict + one-line summary (always visible) → themes/highlights (in `
`) - ### Review Focus - **Focus on changed lines only** — do not review the entire codebase - **Default to skepticism** — assume code is fragile until verified otherwise - **Quality over quantity** — fewer precise, high-signal blocking comments beat many vague comments - **Be constructive but uncompromising** — critique the code, not the author; explain the rationale - **Respect time** — complete within the 15-minute timeout -- **Avoid friendliness padding** — no empty compliments, no generic "looks good"; brief praise is allowed only for clearly exceptional implementation choices ## agent: `grumpy-coder` --- description: Hyper-critical senior reviewer that aggressively finds merge-blocking issues in changed lines diff --git a/.github/workflows/shared/pr-diff-data-fetch.md b/.github/workflows/shared/pr-diff-data-fetch.md index 3ab5794b9f0..b94d6e90a2f 100644 --- a/.github/workflows/shared/pr-diff-data-fetch.md +++ b/.github/workflows/shared/pr-diff-data-fetch.md @@ -3,7 +3,7 @@ # Works for both pull_request events and slash_command (issue) events on PRs. # # Outputs written to: -# /tmp/gh-aw/agent/pr-diff.patch — unified diff (up to 3000 lines) +# /tmp/gh-aw/agent/pr-diff.patch — unified diff (up to 2000 lines) # /tmp/gh-aw/agent/pr-meta.json — PR metadata (number, title, body, etc.) # /tmp/gh-aw/agent/pr-review-comments.json — existing inline review comments # @@ -25,7 +25,7 @@ pre-agent-steps: PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} EXPR_GITHUB_REPOSITORY: ${{ github.repository }} - PR_DIFF_MAX_LINES: "3000" + PR_DIFF_MAX_LINES: "2000" run: | set -euo pipefail mkdir -p /tmp/gh-aw/agent @@ -101,7 +101,7 @@ before the reviewer agents start. | File | Content | |---|---| -| `/tmp/gh-aw/agent/pr-diff.patch` | Unified diff (lock/generated/dist/build excluded, capped at 3000 lines) | +| `/tmp/gh-aw/agent/pr-diff.patch` | Unified diff (lock/generated/dist/build excluded, capped at 2000 lines) | | `/tmp/gh-aw/agent/pr-meta.json` | `number, title, body, headRefName, additions, deletions, changedFiles, files` | | `/tmp/gh-aw/agent/pr-review-comments.json` | Array of `{id, path, line, body, user}` (body capped at 200 chars) | --> diff --git a/actions/setup/js/notify_comment_error.cjs b/actions/setup/js/notify_comment_error.cjs index 17f6fe0f062..02fcf63bf38 100644 --- a/actions/setup/js/notify_comment_error.cjs +++ b/actions/setup/js/notify_comment_error.cjs @@ -13,6 +13,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs"); /** * Collect generated asset URLs from safe output jobs @@ -258,6 +259,25 @@ async function main() { }); } + // Build the generated footer (attribution + XML marker). Appended after sanitization + // so that the XML traceability marker is not stripped by sanitizeContent. + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? ""; + const triggeringIssueNumber = context.payload?.issue?.number; + const triggeringPRNumber = context.payload?.pull_request?.number; + const triggeringDiscussionNumber = context.payload?.discussion?.number; + const markdownParts = assembleMarkdownBodyParts({ + includeFooter: true, + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber, + }); + const footer = markdownParts.footer; + // Add "needs-review" label when detection produced a warning if (detectionConclusion === "warning") { await tryAddNeedsReviewLabel(commentRepo); @@ -309,7 +329,7 @@ async function main() { } }`; - const sanitizedMessage = sanitizeContent(message); + const sanitizedMessage = sanitizeContent(message) + "\n\n" + footer; const variables = replyToId ? { dId: discussionId, body: sanitizedMessage, replyToId } : { dId: discussionId, body: sanitizedMessage }; const result = await github.graphql(mutation, variables); const created = result?.addDiscussionComment?.comment; @@ -326,7 +346,7 @@ async function main() { return; } - const sanitizedMessage = sanitizeContent(message); + const sanitizedMessage = sanitizeContent(message) + "\n\n" + footer; const response = await github.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", { owner: repoOwner, repo: repoName, @@ -364,7 +384,7 @@ async function main() { // Check if this is a discussion comment (GraphQL node ID format) const isDiscussionComment = commentId.startsWith("DC_"); - const sanitizedMessage = sanitizeContent(message); + const sanitizedMessage = sanitizeContent(message) + "\n\n" + footer; try { if (isDiscussionComment) { diff --git a/actions/setup/js/notify_comment_error.test.cjs b/actions/setup/js/notify_comment_error.test.cjs index 3d376e43671..e9eb3a2b7c0 100644 --- a/actions/setup/js/notify_comment_error.test.cjs +++ b/actions/setup/js/notify_comment_error.test.cjs @@ -331,7 +331,7 @@ const mockCore = { (process.env.GH_AW_SAFE_OUTPUT_JOBS = JSON.stringify({ create_issue: "issue_url" })), await eval(`(async () => { ${notifyCommentScript}; await main(); })()`)); const callArgs = mockGithub.request.mock.calls[0][1]; - expect(callArgs.body).toMatch(/completed successfully!$/); + expect(callArgs.body).toContain("completed successfully!"); }), it("should handle empty safe output jobs gracefully", async () => { ((process.env.GH_AW_COMMENT_ID = "123456"), @@ -340,7 +340,7 @@ const mockCore = { (process.env.GH_AW_AGENT_CONCLUSION = "success"), await eval(`(async () => { ${notifyCommentScript}; await main(); })()`)); const callArgs = mockGithub.request.mock.calls[0][1]; - expect(callArgs.body).toMatch(/completed successfully!$/); + expect(callArgs.body).toContain("completed successfully!"); })); }), describe("when safe_outputs job fails", () => { @@ -373,5 +373,27 @@ const mockCore = { await eval(`(async () => { ${notifyCommentScript}; await main(); })()`), expect(mockGithub.request).toHaveBeenCalledWith("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", expect.objectContaining({ body: expect.stringContaining("completed successfully!") }))); })); + }), + describe("footer in status comment", () => { + (it("should include the generated footer in the updated comment body", async () => { + ((process.env.GH_AW_COMMENT_ID = "123456"), + (process.env.GH_AW_RUN_URL = "https://github.com/owner/repo/actions/runs/123"), + (process.env.GH_AW_WORKFLOW_NAME = "test-workflow"), + (process.env.GH_AW_AGENT_CONCLUSION = "success"), + await eval(`(async () => { ${notifyCommentScript}; await main(); })()`)); + const callArgs = mockGithub.request.mock.calls[0][1]; + expect(callArgs.body).toMatch(/Generated by \[test-workflow\]/); + expect(callArgs.body).toMatch(/gh-aw-agentic-workflow/); + }), + it("should include the generated footer even when agent fails", async () => { + ((process.env.GH_AW_COMMENT_ID = "123456"), + (process.env.GH_AW_RUN_URL = "https://github.com/owner/repo/actions/runs/123"), + (process.env.GH_AW_WORKFLOW_NAME = "test-workflow"), + (process.env.GH_AW_AGENT_CONCLUSION = "failure"), + await eval(`(async () => { ${notifyCommentScript}; await main(); })()`)); + const callArgs = mockGithub.request.mock.calls[0][1]; + expect(callArgs.body).toMatch(/Generated by \[test-workflow\]/); + expect(callArgs.body).toMatch(/gh-aw-agentic-workflow/); + })); })); })); diff --git a/docs/adr/51154-split-awf-helpers-into-focused-modules.md b/docs/adr/51154-split-awf-helpers-into-focused-modules.md new file mode 100644 index 00000000000..c744e552cf0 --- /dev/null +++ b/docs/adr/51154-split-awf-helpers-into-focused-modules.md @@ -0,0 +1,49 @@ +# ADR-51154: Split AWF Helpers into Focused Single-Responsibility Modules + +**Date**: 2026-08-07 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +`pkg/workflow/awf_helpers.go` had grown into a monolithic file mixing several unrelated concerns: AWF command and argument assembly, environment variable exclusion, ARC/DinD path rewriting and image digest helpers, and AWF version capability gates. As new features were added to each area, the file became difficult to navigate, review, and test independently. The existing public API (`BuildAWFCommand`, `BuildAWFArgs`, `ComputeAWFExcludeEnvVarNames`, etc.) remained stable, but the implementation had become a maintenance liability. + +### Decision + +We will split `awf_helpers.go` into four focused modules within the same `workflow` package, grouped by responsibility: command and argument assembly (`awf_command_builder.go`), environment variable filtering and max-AI-credits helpers (`awf_env.go`), ARC/DinD path rewriting and container digest helpers (`awf_arc_dind.go`), and AWF version capability gates (`awf_feature_flags.go`). The residual `awf_helpers.go` retains shared constants, the config type, and small scaffolding that is referenced by all modules. The public API is unchanged. + +### Alternatives Considered + +#### Alternative 1: Keep Everything in a Single File + +Retain `awf_helpers.go` as-is and instead apply in-file region comments (e.g., `// --- ARC/DinD ---`) to separate concerns visually. This requires zero structural change and has no merge-conflict risk. + +Not chosen because the file was already ~900 lines and growing. Region comments do not enforce boundaries, do not help IDEs surface individual concerns, and would still require reviewers to parse the entire file. The problem recurs as each area continues to grow. + +#### Alternative 2: Extract Each Concern into Its Own Sub-Package + +Move each concern into a child package under `pkg/workflow/` (e.g., `pkg/workflow/awfcmd`, `pkg/workflow/awfenv`). This is the strongest form of separation and enables independent import graphs. + +Not chosen because it requires changing all call sites to use the new package paths, breaks the existing `workflow`-internal function references (ARC/DinD helpers use unexported helpers shared with the command builder), and introduces an import cycle risk given the circular cross-references between concerns. The split-within-same-package approach achieves improved navigability at lower refactoring cost. + +### Consequences + +#### Positive +- Each module is independently navigable and can be reviewed or tested in isolation. +- Future additions to a specific concern have a clear, well-scoped home, reducing scope creep into unrelated files. +- Test files for individual concerns (e.g., env helper edge cases) can target a single module without touching the others. +- No caller changes required — the public API is preserved exactly. + +#### Negative +- More files in a single package means contributors must remember which file contains which function; Go's intra-package visibility means there is no enforced boundary. +- The split does not prevent future drift back toward a monolith if new helpers are placed in `awf_helpers.go` without discipline. + +#### Neutral +- Existing tests that exercise the public API continue to work without modification, since function signatures and package paths are unchanged. +- The `awf_helpers.go` residual file retains shared constants and types; this file will still grow if new cross-cutting constants are added. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/workflow/awf_arc_dind.go b/pkg/workflow/awf_arc_dind.go new file mode 100644 index 00000000000..e691f3e43d5 --- /dev/null +++ b/pkg/workflow/awf_arc_dind.go @@ -0,0 +1,109 @@ +// This file contains ARC/DinD path rewriting, chroot patch, and image digest helpers. + +package workflow + +import ( + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/constants" +) + +func rewriteArcDindPath(path string) string { + return strings.ReplaceAll(path, constants.TmpGhAwDir, awfArcDindRootPathExpr) +} + +func rewriteArcDindEngineCommand(command string) string { + rewritten := rewriteArcDindPath(command) + return fmt.Sprintf("export HOME=%s\n%s", awfArcDindHomePathExpr, rewritten) +} + +// buildAWFImageTagWithDigests returns an image tag value for AWF's --image-tag flag. +// When known firewall container digests are available, it appends AWF's digest +// metadata format: +// +// ,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:... +// +// For arc-dind topology, build-tools is also included: +// +// ,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...,build-tools=sha256:... +// +// This keeps AWF sidecar configuration aligned with digest-pinned pre-download images. +func buildAWFImageTagWithDigests(imageTag string, workflowData *WorkflowData) string { + if imageTag == "" { + return imageTag + } + + type digestSpec struct { + name string + image string + } + specs := []digestSpec{ + {name: "squid", image: constants.DefaultFirewallRegistry + "/squid:" + imageTag}, + {name: "agent", image: constants.DefaultFirewallRegistry + "/agent:" + imageTag}, + {name: "agent-act", image: constants.DefaultFirewallRegistry + "/agent-act:" + imageTag}, + {name: "api-proxy", image: constants.DefaultFirewallRegistry + "/api-proxy:" + imageTag}, + {name: "cli-proxy", image: constants.DefaultFirewallRegistry + "/cli-proxy:" + imageTag}, + } + if isArcDindTopology(workflowData) { + specs = append(specs, digestSpec{name: "build-tools", image: constants.DefaultFirewallRegistry + "/build-tools:" + imageTag}) + } + + parts := []string{imageTag} + for _, spec := range specs { + digest := lookupContainerDigest(spec.image, workflowData) + if digest == "" { + continue + } + parts = append(parts, spec.name+"="+digest) + } + + if len(parts) == 1 { + return imageTag + } + return strings.Join(parts, ",") +} + +// lookupContainerDigest resolves a container image digest from cache first, then +// falls back to embedded container pins. +func lookupContainerDigest(image string, workflowData *WorkflowData) string { + var cache *ActionCache + if workflowData != nil { + cache = workflowData.ActionCache + } + if pin, ok := lookupContainerPin(image, cache); ok && pin.Digest != "" { + return pin.Digest + } + return "" +} + +// 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://... +// +// Using the repository JavaScript helper avoids a runtime Python dependency and keeps the +// patch logic aligned with the rest of the actions/setup/js helpers. +// The config path under ${RUNNER_TEMP}/gh-aw is updated in place. +func buildArcDindChrootConfigPatchBody() string { + return fmt.Sprintf( + ` GH_AW_CHROOT_BINARIES_SOURCE_PATH="%s" GH_AW_CHROOT_IDENTITY_HOME="%s" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"`, + awfArcDindChrootBinariesSourcePath, + awfArcDindChrootIdentityHome, + ) +} + +// buildArcDindChrootConfigPatchBodyBash returns bash commands (using jq) that patch the AWF +// config file with chroot.binariesSourcePath and chroot.identity.*. This is the bash +// equivalent of buildArcDindChrootConfigPatchBody, used for detection runs where Python +// must not be injected. +// The config path under ${RUNNER_TEMP}/gh-aw is updated in place. +func buildArcDindChrootConfigPatchBodyBash() string { + return fmt.Sprintf( + ` _GH_AW_CHROOT_JSON=$(jq -c --arg src "%s" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "%s" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } + printf '%%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%%s\n' "$_GH_AW_CHROOT_JSON" > "%s/awf-config.json"`, + awfArcDindChrootBinariesSourcePath, + awfArcDindChrootIdentityHome, + awfArcDindChrootBinariesSourcePath, + ) +} diff --git a/pkg/workflow/awf_arc_dind_test.go b/pkg/workflow/awf_arc_dind_test.go new file mode 100644 index 00000000000..2007882d3de --- /dev/null +++ b/pkg/workflow/awf_arc_dind_test.go @@ -0,0 +1,239 @@ +//go:build !integration + +package workflow + +import ( + "fmt" + "os/exec" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArcDindDockerHostDetection(t *testing.T) { + tests := []struct { + name string + dockerHost string + wantDockerHost bool + wantDockerHostV string + }{ + {"tcp://localhost:2375", "tcp://localhost:2375", true, "tcp://localhost:2375"}, + {"tcp://127.0.0.1:2375", "tcp://127.0.0.1:2375", true, "tcp://127.0.0.1:2375"}, + {"tcp://dind:2375 (K8s service name)", "tcp://dind:2375", true, "tcp://dind:2375"}, + {"tcp://172.30.0.5:2375 (pod IP)", "tcp://172.30.0.5:2375", true, "tcp://172.30.0.5:2375"}, + {"tcp://dind-sidecar.default.svc:2376", "tcp://dind-sidecar.default.svc:2376", true, "tcp://dind-sidecar.default.svc:2376"}, + {"unix socket (not tcp)", "unix:///var/run/docker.sock", false, ""}, + {"bare path", "/var/run/docker.sock", false, ""}, + {"empty (unset)", "", false, ""}, + } + + // Build the shell snippet from the constant (same code the compiler emits). + scriptTemplate := fmt.Sprintf(`#!/bin/bash +export DOCKER_HOST="%%s" +GH_AW_DOCKER_HOST="" +if [[ "${DOCKER_HOST:-}" =~ %s ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" +fi +printf 'docker-host=%%%%s\n' "$GH_AW_DOCKER_HOST" +`, awfArcDindDockerHostRegex) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script := fmt.Sprintf(scriptTemplate, tt.dockerHost) + cmd := exec.Command("bash", "-c", script) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "bash script should succeed, output: %s", string(out)) + + gotDockerHost := strings.TrimPrefix(strings.TrimSpace(string(out)), "docker-host=") + if tt.wantDockerHost { + assert.Equal(t, tt.wantDockerHostV, gotDockerHost, + "expected docker host passthrough value to be set for DOCKER_HOST=%s", tt.dockerHost) + } else { + assert.Empty(t, gotDockerHost, + "expected docker host passthrough value to NOT be set for DOCKER_HOST=%s", tt.dockerHost) + } + }) + } +} + +func TestBuildAWFCommand_IncludesChrootInjectScript(t *testing.T) { + t.Run("chroot inject script present when AWF version supports it", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + EngineCommand: "copilot --prompt-file /tmp/prompt.txt", + LogFile: "/tmp/gh-aw/agent-stdio.log", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Version: string(constants.AWFChrootConfigMinVersion), + }, + }, + }, + } + command := BuildAWFCommand(config) + assert.Contains(t, command, awfArcDindChrootBinariesSourcePath, + "command should include the expected binariesSourcePath constant") + assert.Contains(t, command, awfArcDindChrootIdentityHome, + "command should include the expected identity.home constant") + assert.Contains(t, command, `node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"`, + "command should invoke the repository JavaScript helper for chroot config patching") + assert.NotContains(t, command, "python3 - <<'PY'", + "command should not inject an inline Python heredoc") + assert.Contains(t, command, awfArcDindDockerHostRegex, + "chroot inject script should reuse the DinD Docker host regex") + // Structural: the chroot injection must appear *after* the DOCKER_HOST guard, + // confirming it is nested inside the if-block and not emitted at top level. + dockerhostIdx := strings.Index(command, awfArcDindDockerHostRegex) + helperIdx := strings.Index(command, "patch_awf_chroot_config.cjs") + assert.Greater(t, helperIdx, dockerhostIdx, + "chroot injection must appear after the DOCKER_HOST guard in the generated script") + }) + + t.Run("chroot inject script absent when AWF version too old", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + EngineCommand: "copilot --prompt-file /tmp/prompt.txt", + LogFile: "/tmp/gh-aw/agent-stdio.log", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Version: "v0.27.0", + }, + }, + }, + } + command := BuildAWFCommand(config) + assert.NotContains(t, command, "binariesSourcePath", + "command should NOT include chroot inject script for old AWF version") + }) +} + +func TestRewriteArcDindPath(t *testing.T) { + t.Run("rewrites tmp gh-aw prefix", func(t *testing.T) { + assert.Equal(t, "${RUNNER_TEMP}/gh-aw/aw-prompts/prompt.txt", rewriteArcDindPath("/tmp/gh-aw/aw-prompts/prompt.txt")) + }) + + t.Run("rewrites multiple occurrences", func(t *testing.T) { + input := "/tmp/gh-aw/a /tmp/gh-aw/b" + expected := "${RUNNER_TEMP}/gh-aw/a ${RUNNER_TEMP}/gh-aw/b" + assert.Equal(t, expected, rewriteArcDindPath(input)) + }) + + t.Run("leaves unrelated paths unchanged", func(t *testing.T) { + assert.Equal(t, "/tmp/not-gh-aw/file.txt", rewriteArcDindPath("/tmp/not-gh-aw/file.txt")) + }) +} + +func TestRewriteArcDindEngineCommand(t *testing.T) { + command := "copilot --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt" + rewritten := rewriteArcDindEngineCommand(command) + + assert.Contains(t, rewritten, "export HOME=${RUNNER_TEMP}/gh-aw/home") + assert.Contains(t, rewritten, "copilot --prompt-file ${RUNNER_TEMP}/gh-aw/aw-prompts/prompt.txt") +} + +func TestBuildAWFImageTagWithDigests(t *testing.T) { + t.Run("includes digest metadata for known firewall images", func(t *testing.T) { + imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") + tag := buildAWFImageTagWithDigests(imageTag, nil) + + assert.Contains(t, tag, imageTag, "should keep original AWF tag") + assert.Contains(t, tag, "squid=sha256:", "should include squid digest metadata") + assert.Contains(t, tag, "agent=sha256:", "should include agent digest metadata") + assert.Contains(t, tag, "api-proxy=sha256:", "should include api-proxy digest metadata") + assert.Contains(t, tag, "cli-proxy=sha256:", "should include cli-proxy digest metadata") + }) + + t.Run("leaves tag unchanged when digests are unavailable", func(t *testing.T) { + tag := buildAWFImageTagWithDigests("0.0.1", nil) + assert.Equal(t, "0.0.1", tag, "should not append digest metadata when no pins are available") + }) + + t.Run("includes build-tools digest for arc-dind topology", func(t *testing.T) { + imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") + buildToolsImage := constants.DefaultFirewallRegistry + "/build-tools:" + imageTag + cache := &ActionCache{ContainerPins: make(map[string]ContainerPin)} + cache.SetContainerPin( + buildToolsImage, + "sha256:1111111111111111111111111111111111111111111111111111111111111111", + buildToolsImage+"@sha256:1111111111111111111111111111111111111111111111111111111111111111", + ) + workflowData := &WorkflowData{ + RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, + ActionCache: cache, + } + tag := buildAWFImageTagWithDigests(imageTag, workflowData) + + assert.Contains(t, tag, "build-tools=sha256:", "should include build-tools digest metadata for arc-dind topology") + }) + + t.Run("excludes build-tools digest without arc-dind topology", func(t *testing.T) { + imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") + tag := buildAWFImageTagWithDigests(imageTag, nil) + + assert.NotContains(t, tag, "build-tools=", "should not include build-tools digest metadata without arc-dind topology") + }) +} + +func TestBuildAWFArgs_ImageTagIncludesDigests(t *testing.T) { + // Use the default firewall version so this test tracks pin/version updates. + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.DefaultFirewallVersion)}, + }, + }, + } + + // When the AWF version supports --config (default), --image-tag moves to the JSON config file. + // Verify the config file JSON contains the image tag with digest metadata. + awfConfigJSON, err := BuildAWFConfigJSON(config) + require.NoError(t, err, "BuildAWFConfigJSON should not error") + assert.Contains(t, awfConfigJSON, "imageTag", "expected imageTag in AWF config JSON") + assert.Contains(t, awfConfigJSON, "squid=sha256:", "expected squid digest metadata in AWF config JSON") + assert.Contains(t, awfConfigJSON, "agent=sha256:", "expected agent digest metadata in AWF config JSON") + assert.Contains(t, awfConfigJSON, "api-proxy=sha256:", "expected api-proxy digest metadata in AWF config JSON") + + // --image-tag should NOT appear in the CLI args (it's in the config file). + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + assert.NotContains(t, argsStr, "--image-tag", "expected --image-tag to be absent from CLI args when config file is used") +} + +func TestBuildAWFCommand_ArcDindPreCreatesMountDirs(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + EngineCommand: "copilot run", + LogFile: "/tmp/log.txt", + PathSetup: "export PATH=/usr/bin:$PATH", + WorkflowData: &WorkflowData{ + Name: "Test", + AI: "copilot", + MarkdownContent: "test", + RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + }, + } + + command := BuildAWFCommand(config) + + // Verify mount source directories are pre-created before AWF invocation + assert.Contains(t, command, `mkdir -p "${RUNNER_TEMP}/gh-aw/home" "${RUNNER_TEMP}/gh-aw/sandbox/agent"`, + "should pre-create rw mount source directories for arc-dind") + + // Verify the mounts themselves are present + assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/home:${RUNNER_TEMP}/gh-aw/home:rw"`) + assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/sandbox/agent:${RUNNER_TEMP}/gh-aw/sandbox/agent:rw"`) +} diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go new file mode 100644 index 00000000000..4f3673575e5 --- /dev/null +++ b/pkg/workflow/awf_command_builder.go @@ -0,0 +1,644 @@ +// This file contains AWF command and argument assembly helpers. + +package workflow + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/workflow/compilerenv" +) + +// BuildAWFCommand builds a complete AWF command with all arguments. +// This consolidates the AWF command building logic that was duplicated across +// Copilot, Claude, and Codex engines. +// +// Parameters: +// - config: AWF command configuration +// +// Returns: +// - string: Complete AWF command with arguments and wrapped engine command +func BuildAWFCommand(config AWFCommandConfig) string { + awfHelpersLog.Printf("Building AWF command for engine: %s", config.EngineName) + isArcDind := isArcDindTopology(config.WorkflowData) + + // Get AWF command prefix (custom or standard) + awfCommand := GetAWFCommandPrefix(config.WorkflowData) + + // Build AWF arguments. The returned list contains only args that are safe to pass + // through shellJoinArgs. Expandable-var args (--container-workdir "${GITHUB_WORKSPACE}" + // and --mount "${RUNNER_TEMP}/...") are appended raw below so that shell variable + // expansion is not suppressed by single-quoting. + awfArgs := BuildAWFArgs(config) + firewallConfig := getFirewallConfig(config.WorkflowData) + + // Auto-detect ARC/DinD split daemon topology at runtime: probe DOCKER_HOST for a + // tcp:// scheme and pass it through to AWF via --docker-host. + // All behaviors avoid requiring workflow-authored sandbox.agent.args for standard ARC DinD setups. + // When AWF also supports chroot config (v0.27.1+), the Python patch body is embedded inside + // the same if-block so the script only contains one DOCKER_HOST condition check. + arcDindPrefixProbe := "" + arcDindDockerHostProbe := fmt.Sprintf(`%s="" +if [[ "${DOCKER_HOST:-}" =~ %s ]]; then + %s="${DOCKER_HOST}" +fi`, + awfDockerHostVarName, + awfArcDindDockerHostRegex, + awfDockerHostVarName, + ) + arcDindDockerHostRef := fmt.Sprintf("${%s:+--docker-host \"$%s\"}", awfDockerHostVarName, awfDockerHostVarName) + if awfSupportsDockerHostPathPrefix(firewallConfig) { + chrootPatchBody := "" + if awfSupportsChrootConfig(firewallConfig) { + if config.WorkflowData != nil && config.WorkflowData.IsDetectionRun { + chrootPatchBody = "\n" + buildArcDindChrootConfigPatchBodyBash() + } else { + chrootPatchBody = "\n" + buildArcDindChrootConfigPatchBody() + } + } + // NOTE: --docker-host-path-prefix is intentionally NOT passed. With sysroot-stage + // active, all bind-mount source paths are on the shared work volume and visible to + // the Docker daemon without translation. The prefix caused AWF to translate + // GITHUB_WORKSPACE to a non-existent path, resulting in an empty workspace (gh-aw#34896). + // The probe block is preserved for the chroot config patch which still requires the + // DOCKER_HOST guard. + if chrootPatchBody != "" { + arcDindPrefixProbe = fmt.Sprintf(`if [[ "${DOCKER_HOST:-}" =~ %s ]]; then%s +fi`, + awfArcDindDockerHostRegex, + chrootPatchBody) + } + } + toolCacheMountProbe := fmt.Sprintf(`%s="" +GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" +if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + %s="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi +fi`, + awfToolCacheMountVarName, + awfToolCacheMountVarName, + ) + toolCacheMountRef := fmt.Sprintf("${%s:+--mount \"$%s\"}", awfToolCacheMountVarName, awfToolCacheMountVarName) + + // Build the expandable args string for args that need shell variable expansion. + // These MUST be appended as raw (unescaped) strings because single-quoting would + // prevent the runner's shell from expanding ${GITHUB_WORKSPACE} and ${RUNNER_TEMP}. + ghAwDir := constants.GhAwRootDirShell + expandableArgs := fmt.Sprintf( + `--container-workdir "${GITHUB_WORKSPACE}" --mount "%s:%s:ro" --mount "%s:/host%s:ro"`, + ghAwDir, ghAwDir, ghAwDir, ghAwDir, + ) + if isArcDind { + expandableArgs += fmt.Sprintf( + ` --mount "%s:%s:rw" --mount "%s:%s:rw"`, + awfArcDindHomePathExpr, awfArcDindHomePathExpr, + awfArcDindRootPathExpr+"/sandbox/agent", awfArcDindRootPathExpr+"/sandbox/agent", + ) + // Explicitly mount the workspace so AWF can see it without path-prefix translation. + // GITHUB_WORKSPACE is on the shared work volume, so the Docker daemon can access it. + expandableArgs += ` --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw"` + // Pre-create the rw mount source directories. AWF validates that mount source + // paths exist before starting containers, so these must be created on the host + // before the AWF invocation. The parent ${RUNNER_TEMP}/gh-aw/ already exists + // (created by actions/setup), but the subdirectories may not. + arcDindDockerHostProbe += fmt.Sprintf("\nmkdir -p \"%s\" \"%s\"", + awfArcDindHomePathExpr, + awfArcDindRootPathExpr+"/sandbox/agent", + ) + // Copy prompt files to daemon-visible path. On ARC/DinD, /tmp/gh-aw/ is NOT + // accessible to the Docker daemon. The activation job writes prompts to + // /tmp/gh-aw/aw-prompts/, so we copy them to ${RUNNER_TEMP}/gh-aw/aw-prompts/. + arcDindDockerHostProbe += fmt.Sprintf("\nif [ -d /tmp/gh-aw/aw-prompts ]; then cp -a /tmp/gh-aw/aw-prompts \"%s/aw-prompts\"; fi", + awfArcDindRootPathExpr, + ) + } + + // Generate a JSON config file and reference it via --config "${RUNNER_TEMP}/gh-aw/awf-config.json". + // This replaces several verbose CLI flags (--allow-domains, --enable-api-proxy, --image-tag, + // API targets) with a structured JSON file that is easier to audit and extend. + // + // The config file is written at runtime (inside the run: step) immediately before the AWF + // invocation, using printf to a fixed path inside the pre-existing ${RUNNER_TEMP}/gh-aw/ + // directory that is already set up by actions/setup. + var configFileSetup string + awfConfigJSON, err := BuildAWFConfigJSON(config) + if err != nil { + awfHelpersLog.Printf("Warning: failed to build AWF config JSON: %v", err) + } else { + // When max-ai-credits is not set by frontmatter/imports, export a local shell + // variable (GH_AW_MAX_AI_CREDITS) holding a GitHub Actions runtime expression, + // then inject a reference to that variable (${GH_AW_MAX_AI_CREDITS}) into the + // "maxAiCredits" field of the apiProxy JSON object. GitHub Actions evaluates + // the ${{ }} expression before the shell runs, so the variable is set to the + // resolved integer by the time printf writes the config file. + // + // Standard agent runs use vars.GH_AW_DEFAULT_MAX_AI_CREDITS with built-in + // fallback 1000. Threat-detection runs use + // vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS with built-in fallback 400. + // Evals runs use vars.GH_AW_DEFAULT_EVALS_MAX_AI_CREDITS with built-in + // fallback 400 to align with detection budgets. + // EngineConfig.MaxAICredits is 0 when no compile-time value was set + // (neither frontmatter nor detection-engine config provided one). + // In that case, emit a runtime expression that lets the org variable + // or the built-in default resolve the budget at action run time. + // For detection runs, use the detection-specific variable/fallback; + // for standard agent runs, use the main-agent variable/fallback. + var maxAICreditsExportLine string + if config.WorkflowData == nil || config.WorkflowData.EngineConfig == nil || config.WorkflowData.EngineConfig.MaxAICredits == 0 { + defaultMaxAICredits := strconv.FormatInt(constants.DefaultMaxAICredits, 10) + if config.WorkflowData != nil { + switch { + case config.WorkflowData.IsEvalsRun: + defaultMaxAICredits = strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10) + case config.WorkflowData.IsDetectionRun: + defaultMaxAICredits = strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10) + } + } + awfConfigJSON = injectMaxAICreditsExpression(awfConfigJSON, fmt.Sprintf("${%s}", awfMaxAICreditsVarName)) + if config.ResolveMaxAICreditsFromEnv { + maxAICreditsExportLine = fmt.Sprintf(`%s="${%s:-%s}"`, awfMaxAICreditsVarName, awfMaxAICreditsVarName, defaultMaxAICredits) + } else { + expr := compilerenv.BuildDefaultMaxAICreditsExpression(defaultMaxAICredits) + if config.WorkflowData != nil { + switch { + case config.WorkflowData.IsEvalsRun: + expr = compilerenv.BuildDefaultEvalsMaxAICreditsExpression(defaultMaxAICredits) + case config.WorkflowData.IsDetectionRun: + expr = compilerenv.BuildDefaultDetectionMaxAICreditsExpression(defaultMaxAICredits) + } + } + maxAICreditsExportLine = fmt.Sprintf(`%s="%s"`, awfMaxAICreditsVarName, expr) + } + awfHelpersLog.Printf("Injected maxAiCredits local var reference into AWF config JSON") + } + // Write the config JSON to ${RUNNER_TEMP}/gh-aw/awf-config.json before AWF runs. + // When the generated JSON contains compiler-owned runtime variables such as + // ${GH_AW_MAX_AI_CREDITS} or ${RUNNER_TEMP}, use shellEscapeArgWithVarsPreserved + // which always uses double-quote wrapping: it escapes bare $ signs (e.g. + // "$schema" → "\$schema") while preserving both ${{ }} GitHub Actions expressions + // (e.g. in AllowedDomains) and approved shell variable references so bash expands + // them to runtime-resolved values. When no such variables are injected, + // shellEscapeArg handles escaping normally. + // Also copy it to /tmp/gh-aw/awf-config.json for the unified agent artifact upload. + var printfArg string + preservedVars := make([]string, 0, 2) + if maxAICreditsExportLine != "" { + preservedVars = append(preservedVars, awfMaxAICreditsVarName) + } + if strings.Contains(awfConfigJSON, awfArcDindRootPathExpr) { + preservedVars = append(preservedVars, "RUNNER_TEMP") + } + if len(preservedVars) > 0 { + printfArg = shellEscapeArgWithVarsPreserved(awfConfigJSON, preservedVars...) + } else { + printfArg = shellEscapeArg(awfConfigJSON) + } + // SC2016 ("Expressions don't expand in single quotes") is only triggered when + // printfArg is single-quoted (no runtime variables injected). Double-quoted args + // already escape bare $ signs as \$schema, so shellcheck does not warn there. + var printfLine string + if strings.HasPrefix(printfArg, "'") { + printfLine = "# shellcheck disable=SC2016\nprintf '%%s\\n' %s > %q" + } else { + printfLine = "printf '%%s\\n' %s > %q" + } + configFileSetup = fmt.Sprintf(printfLine, printfArg, awfConfigRuntimePathExpr) + if maxAICreditsExportLine != "" { + configFileSetup = maxAICreditsExportLine + "\n" + configFileSetup + } + if shouldUseWorkflowCallNetworkAllowedInput(config.WorkflowData) { + updateScript, updateErr := buildWorkflowCallNetworkAllowedUpdateScript() + if updateErr != nil { + awfHelpersLog.Printf("Warning: failed to build workflow_call network_allowed updater: %v", updateErr) + } else { + configFileSetup += "\n" + updateScript + } + } + configFileSetup += fmt.Sprintf("\ncp %q %s", awfConfigRuntimePathExpr, constants.AWFConfigFilePath) + // Add --config as the first expandable arg so it appears before --container-workdir. + expandableArgs = fmt.Sprintf("--config %q ", awfConfigRuntimePathExpr) + expandableArgs + awfHelpersLog.Print("Using AWF config file (--config flag)") + } + modelsJSONPathExport := buildModelsJSONPathExportScript(isArcDind) + + // When upload_artifact is configured, add a read-write mount for the staging directory + // so the model can copy files there from inside the container. The parent ${RUNNER_TEMP}/gh-aw + // is mounted :ro above; this child mount overrides access for the staging subdirectory only. + // The staging directory must already exist on the host (created in Generate Safe Outputs Config step). + if config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil { + stagingDir := SafeOutputsUploadArtifactsDir + expandableArgs += fmt.Sprintf(` --mount "%s:%s:rw"`, stagingDir, stagingDir) + awfHelpersLog.Print("Added read-write mount for upload_artifact staging directory") + } + + // 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..ports[''] }} expressions that include single quotes. + // These expressions are resolved by the GitHub Actions runner before shell execution, + // so they must not be shell-escaped. + 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 + if isArcDind { + engineCommand = rewriteArcDindEngineCommand(engineCommand) + } + + // Wrap engine command in shell (command already includes any internal setup like npm PATH) + shellWrappedCommand := WrapCommandInShell(engineCommand) + + // Pre-create the agent stdio log file with restrictive permissions (0600) before + // starting the AWF container. tee would otherwise create it with the default + // umask (0644), leaving secrets (e.g. MCP gateway tokens) world-readable on the + // runner host until the secret-redaction step runs. + preCreateLog := fmt.Sprintf("(umask 177 && touch %s)", shellEscapeArg(config.LogFile)) + + // Capture the epoch-millisecond timestamp at the very start of the Execute Agent CLI + // step on the host, before the AWF container launches. sendJobConclusionSpan reads + // this file to set the dedicated gh-aw..agent span start time, which excludes + // pre-agent overhead such as workspace audit and CLI proxy startup. + writeAgentCLIStartMs := "printf '%s' \"$(date +%s%3N)\" > " + shellEscapeArg(AgentCLIStartMsPath) + + // Build the complete command with proper formatting. + // configFileSetup (if non-empty) writes the AWF config JSON immediately before the + // AWF invocation so the file is present when AWF parses --config. + // + // shellcheck directive rationale: + // - SC1003 is expected because this generated block intentionally contains GitHub + // expression literals (for example ${{ job.services..ports[''] }}) + // that include single quotes and must survive into runtime unchanged. + // - SC2086 is expected because a subset of AWF arguments are intentionally emitted + // as expandable shell fragments (for example ${GH_AW_TOOL_CACHE_MOUNT:+...} and + // ${GH_AW_DOCKER_HOST:+...}). These fragments are produced by trusted + // compiler-owned probes above and are not user-provided free-form shell input. + // + // We keep normal quoting for all user-controlled values via shellEscapeArg/shellJoinArgs + // and scope this suppression to the generated AWF invocation line only. + var command string + if config.PathSetup != "" && configFileSetup != "" { + command = fmt.Sprintf(`set -o pipefail +%s +%s +%s +%s +%s +%s +%s +%s +%s +%s %s %s %s %s \ + -- %s 2>&1 | tee -a %s`, + writeAgentCLIStartMs, + config.PathSetup, + preCreateLog, + configFileSetup, + modelsJSONPathExport, + arcDindDockerHostProbe, + arcDindPrefixProbe, + toolCacheMountProbe, + awfShellcheckDirective, + awfCommand, + expandableArgs, + toolCacheMountRef, + arcDindDockerHostRef, + shellJoinArgs(awfArgs), + shellWrappedCommand, + shellEscapeArg(config.LogFile)) + } else if config.PathSetup != "" { + // Include path setup before AWF command (runs on host before AWF) + command = fmt.Sprintf(`set -o pipefail +%s +%s +%s +%s +%s +%s +%s +%s +%s %s %s %s %s \ + -- %s 2>&1 | tee -a %s`, + writeAgentCLIStartMs, + config.PathSetup, + preCreateLog, + modelsJSONPathExport, + arcDindDockerHostProbe, + arcDindPrefixProbe, + toolCacheMountProbe, + awfShellcheckDirective, + awfCommand, + expandableArgs, + toolCacheMountRef, + arcDindDockerHostRef, + shellJoinArgs(awfArgs), + shellWrappedCommand, + shellEscapeArg(config.LogFile)) + } else if configFileSetup != "" { + command = fmt.Sprintf(`set -o pipefail +%s +%s +%s +%s +%s +%s +%s +%s +%s %s %s %s %s \ + -- %s 2>&1 | tee -a %s`, + writeAgentCLIStartMs, + preCreateLog, + configFileSetup, + modelsJSONPathExport, + arcDindDockerHostProbe, + arcDindPrefixProbe, + toolCacheMountProbe, + awfShellcheckDirective, + awfCommand, + expandableArgs, + toolCacheMountRef, + arcDindDockerHostRef, + shellJoinArgs(awfArgs), + shellWrappedCommand, + shellEscapeArg(config.LogFile)) + } else { + command = fmt.Sprintf(`set -o pipefail +%s +%s +%s +%s +%s +%s +%s +%s %s %s %s %s \ + -- %s 2>&1 | tee -a %s`, + writeAgentCLIStartMs, + preCreateLog, + modelsJSONPathExport, + arcDindDockerHostProbe, + arcDindPrefixProbe, + toolCacheMountProbe, + awfShellcheckDirective, + awfCommand, + expandableArgs, + toolCacheMountRef, + arcDindDockerHostRef, + shellJoinArgs(awfArgs), + shellWrappedCommand, + shellEscapeArg(config.LogFile)) + } + + awfHelpersLog.Print("Successfully built AWF command") + return command +} + +// BuildAWFArgs constructs common AWF arguments from configuration. +// This extracts the shared AWF argument building logic from engine implementations. +// +// 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 +// - --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 +// +// Returns: +// - []string: List of AWF arguments (safe args only; expandable-var args like +// --container-workdir and --mount are handled by BuildAWFCommand) +func BuildAWFArgs(config AWFCommandConfig) []string { + awfHelpersLog.Printf("Building AWF args for engine: %s", config.EngineName) + + firewallConfig := getFirewallConfig(config.WorkflowData) + agentConfig := getAgentConfig(config.WorkflowData) + + var awfArgs []string + + // Add TTY flag if needed (Claude requires this), except for docker-sbx where + // sbx exec --tty can terminate long-running Claude sessions prematurely. + if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) { + awfArgs = append(awfArgs, "--tty") + } + + // docker-sbx: tell AWF to launch the agent inside a Docker sbx microVM instead + // of as a standard Docker Compose service. Guard on the effective AWF version so + // older binaries do not receive an unknown flag. + if isDockerSbxRuntime(config.WorkflowData) && awfSupportsContainerRuntime(firewallConfig) { + awfArgs = append(awfArgs, "--container-runtime", "sbx") + awfHelpersLog.Print("Added --container-runtime sbx for docker-sbx microVM runtime") + } else if isDockerSbxRuntime(config.WorkflowData) { + awfHelpersLog.Printf("Skipping --container-runtime sbx: AWF version %q is older than required minimum %s", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) + } + + // Pass all environment variables to the container, but exclude every variable whose + // step-env value comes from a GitHub Actions secret. AWF's API proxy (--enable-api-proxy) + // handles authentication for these tokens transparently, so the container does not need + // the raw values. Excluding them via --exclude-env prevents a prompt-injected agent from + // exfiltrating tokens through bash tools such as `env` or `printenv`. + // The caller computes ExcludeEnvVarNames from ComputeAWFExcludeEnvVarNames() so that every + // secret-bearing variable is covered — not just a hardcoded subset. + // --exclude-env requires AWF v0.25.3+; skip the flags for workflows that pin an older version. + awfArgs = append(awfArgs, "--env-all") + if awfSupportsExcludeEnv(firewallConfig) { + // Sort for deterministic output in compiled lock files. + sortedExclude := make([]string, len(config.ExcludeEnvVarNames)) + copy(sortedExclude, config.ExcludeEnvVarNames) + sort.Strings(sortedExclude) + for _, excludedVar := range sortedExclude { + awfArgs = append(awfArgs, "--exclude-env", excludedVar) + } + } else { + awfHelpersLog.Printf("Skipping --exclude-env: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFExcludeEnvMinVersion) + } + + // Note: --container-workdir "${GITHUB_WORKSPACE}" and --mount "${RUNNER_TEMP}/gh-aw:..." + // are intentionally NOT added here. They contain shell variable references that require + // double-quote expansion. These args are appended raw in BuildAWFCommand to ensure + // ${GITHUB_WORKSPACE} and ${RUNNER_TEMP} are expanded by the runner's shell. + + // Add custom mounts from agent config if specified + if agentConfig != nil && len(agentConfig.Mounts) > 0 { + // Sort mounts for consistent output + sortedMounts := make([]string, len(agentConfig.Mounts)) + copy(sortedMounts, agentConfig.Mounts) + sort.Strings(sortedMounts) + + for _, mount := range sortedMounts { + awfArgs = append(awfArgs, "--mount", mount) + } + awfHelpersLog.Printf("Added %d custom mounts from agent config", len(sortedMounts)) + } + + // Set log level + awfLogLevel := string(constants.AWFDefaultLogLevel) + if firewallConfig != nil && firewallConfig.LogLevel != "" { + awfLogLevel = firewallConfig.LogLevel + } + awfArgs = append(awfArgs, "--log-level", awfLogLevel) + if isFeatureEnabled(constants.AwfDiagnosticLogsFeatureFlag, config.WorkflowData) { + awfArgs = append(awfArgs, "--diagnostic-logs") + awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled") + } + + // Legacy security mode: emit --legacy-security, --enable-host-access, and --allow-host-ports + isLegacy := agentConfig != nil && agentConfig.LegacySecurity + if isLegacy { + 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 legacy security mode") + + if awfSupportsAllowHostPorts(firewallConfig) { + mcpGatewayPort := int(DefaultMCPGatewayPort) + if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && + config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 { + mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port + } + hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort) + awfArgs = append(awfArgs, "--allow-host-ports", hostPorts) + awfHelpersLog.Printf("Added --allow-host-ports %s for legacy security mode", hostPorts) + } + } else { + awfHelpersLog.Print("Strict security: skipping host-access flags (default)") + } + + // Skip pulling images since they are pre-downloaded + awfArgs = append(awfArgs, "--skip-pull") + awfHelpersLog.Print("Using --skip-pull since images are pre-downloaded") + + // Enable CLI proxy sidecar when GitHub mode is gh-proxy. + // Start the difc-proxy on the host and tell AWF where to connect + // (firewall v0.25.17+). + if isGitHubCLIModeEnabled(config.WorkflowData) { + if awfSupportsCliProxy(firewallConfig) { + difcProxyHost := "host.docker.internal:18443" + if isAWFNetworkIsolationEnabled(config.WorkflowData) { + difcProxyHost = "awmg-cli-proxy:18443" + } + awfArgs = append(awfArgs, "--difc-proxy-host", difcProxyHost) + awfArgs = append(awfArgs, "--difc-proxy-ca-cert", constants.TmpDIFCProxyTLSCACert) + awfHelpersLog.Print("Added --difc-proxy-host and --difc-proxy-ca-cert for CLI proxy sidecar") + } else { + awfHelpersLog.Printf("Skipping CLI proxy flags: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFCliProxyMinVersion) + } + } + + // Pass base path if URL contains a path component + // This is required for endpoints with path prefixes (e.g., Databricks /serving-endpoints, + // Azure OpenAI /openai/deployments/, corporate LLM routers with path-based routing) + // Base paths remain as CLI flags — they are not yet represented in the config file schema. + openaiBasePath := extractAPIBasePath(config.WorkflowData, "OPENAI_BASE_URL") + if openaiBasePath != "" { + awfArgs = append(awfArgs, "--openai-api-base-path", openaiBasePath) + awfHelpersLog.Printf("Added --openai-api-base-path=%s", openaiBasePath) + } + + anthropicBasePath := extractAPIBasePath(config.WorkflowData, "ANTHROPIC_BASE_URL") + if anthropicBasePath != "" { + awfArgs = append(awfArgs, "--anthropic-api-base-path", anthropicBasePath) + awfHelpersLog.Printf("Added --anthropic-api-base-path=%s", anthropicBasePath) + } + + geminiBasePath := extractAPIBasePath(config.WorkflowData, "GEMINI_API_BASE_URL") + if geminiBasePath != "" { + awfArgs = append(awfArgs, "--gemini-api-base-path", geminiBasePath) + awfHelpersLog.Printf("Added --gemini-api-base-path=%s", geminiBasePath) + } + + // Add SSL Bump support for HTTPS content inspection (v0.9.0+) + sslBumpArgs := getSSLBumpArgs(firewallConfig) + awfArgs = append(awfArgs, sslBumpArgs...) + + // Add custom args if specified in firewall config + if firewallConfig != nil && len(firewallConfig.Args) > 0 { + awfArgs = append(awfArgs, firewallConfig.Args...) + } + + // Add custom args from agent config if specified + if agentConfig != nil && len(agentConfig.Args) > 0 { + awfArgs = append(awfArgs, agentConfig.Args...) + awfHelpersLog.Printf("Added %d custom args from agent config", len(agentConfig.Args)) + } + + // Pass memory limit to AWF container if specified in agent config + if agentConfig != nil && agentConfig.Memory != "" { + awfArgs = append(awfArgs, "--memory-limit", agentConfig.Memory) + awfHelpersLog.Printf("Set AWF memory limit to %s", agentConfig.Memory) + } + + awfHelpersLog.Printf("Built %d AWF arguments", len(awfArgs)) + return awfArgs +} + +// GetAWFCommandPrefix determines the AWF command to use (custom or standard). +// This extracts the common pattern for determining AWF command from agent config. +// +// Parameters: +// - workflowData: The workflow data containing agent configuration +// +// Returns: +// - string: The AWF command to use (e.g., "sudo -E awf", "awf", or custom command) +func GetAWFCommandPrefix(workflowData *WorkflowData) string { + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && agentConfig.Command != "" { + awfHelpersLog.Printf("Using custom AWF command: %s", agentConfig.Command) + return agentConfig.Command + } + + // 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) + } + + // Default strict security: AWF runs rootless (no sudo) + awfHelpersLog.Print("Using standard AWF command (strict security, no sudo)") + return string(constants.AWFDefaultCommand) +} + +// WrapCommandInShell wraps an engine command in a shell invocation for AWF execution. +// This is needed because AWF requires commands to be wrapped in shell for proper execution. +// +// set +o histexpand disables bash history expansion so that agent-authored strings +// containing '!' characters (e.g. "!**") cannot be silently misinterpreted or dropped. +// History expansion is meaningless for non-interactive execution and has no other effect. +// +// Parameters: +// - command: The engine command to wrap (may include PATH setup and other initialization) +// +// Returns: +// - string: Shell-wrapped command suitable for AWF execution +func WrapCommandInShell(command string) string { + awfHelpersLog.Print("Wrapping command in shell for AWF execution") + + // Escape single quotes in the command by replacing ' with '\'' + escapedCommand := strings.ReplaceAll(command, "'", "'\\''") + + // Wrap in shell invocation. + // set +o histexpand is first to prevent bash from expanding !-patterns in any + // double-quoted strings that appear in the engine command or its arguments. + return fmt.Sprintf("/bin/bash -c 'set +o histexpand; %s'", escapedCommand) +} diff --git a/pkg/workflow/awf_command_builder_test.go b/pkg/workflow/awf_command_builder_test.go new file mode 100644 index 00000000000..f116cbf9a9c --- /dev/null +++ b/pkg/workflow/awf_command_builder_test.go @@ -0,0 +1,679 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" +) + +func TestBuildAWFArgsAuditDir(t *testing.T) { + t.Run("non-arc-dind omits audit-dir and proxy-logs-dir from CLI flags", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + // Non-ARC/DinD: these should be in config, not CLI flags + assert.NotContains(t, argsStr, "--audit-dir", "audit-dir should be in config for non-arc-dind") + assert.NotContains(t, argsStr, "--proxy-logs-dir", "proxy-logs-dir should be in config for non-arc-dind") + }) + + t.Run("arc-dind also omits audit-dir and proxy-logs-dir from CLI flags", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--audit-dir", "arc-dind audit-dir should be emitted via config JSON") + assert.NotContains(t, argsStr, "--proxy-logs-dir", "arc-dind proxy-logs-dir should be emitted via config JSON") + }) +} + +// TestBuildAWFArgsAllowHostPorts tests that BuildAWFArgs includes --allow-host-ports +// with port 80, 443, and the MCP gateway port so the AWF agent container can reach +// the gateway through the firewall's iptables rules. + +func TestBuildAWFArgsAllowHostPorts(t *testing.T) { + t.Run("includes default MCP gateway port 8080", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{LegacySecurity: true}, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") + assert.Contains(t, argsStr, "80,443,8080", "Should allow default gateway port 8080 alongside 80 and 443") + }) + + t.Run("uses custom MCP gateway port from sandbox config", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{LegacySecurity: true}, + MCP: &MCPGatewayRuntimeConfig{Port: 9090}, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") + assert.Contains(t, argsStr, "80,443,9090", "Should use custom gateway port from sandbox config") + assert.NotContains(t, argsStr, "8080", "Should not include default port when custom port is set") + }) + + t.Run("handles nil SandboxConfig gracefully — strict mode skips host-access", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--allow-host-ports", "Strict mode (default) should not emit --allow-host-ports") + assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode (default) should not emit --enable-host-access") + }) + + t.Run("skips --allow-host-ports when AWF version is too old", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Version: "v0.25.23", + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports for AWF versions below minimum support") + }) + + t.Run("skips host-access flags when network isolation is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + NetworkIsolation: true, + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--enable-host-access", "Should skip --enable-host-access in network isolation mode") + assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports in network isolation mode") + }) +} + +// TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs +// only when features.awf-diagnostic-logs is enabled. + +func TestBuildAWFArgsDiagnosticLogs(t *testing.T) { + baseWorkflow := func(features map[string]any) *WorkflowData { + return &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Features: features, + } + } + + t.Run("does not include --diagnostic-logs when feature flag is absent", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: baseWorkflow(nil), + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--diagnostic-logs", "Should not include --diagnostic-logs when feature flag is absent") + }) + + t.Run("includes --diagnostic-logs when awf-diagnostic-logs is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: baseWorkflow(map[string]any{ + string(constants.AwfDiagnosticLogsFeatureFlag): true, + }), + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--diagnostic-logs", "Should include --diagnostic-logs when feature flag is enabled") + }) +} + +// TestBuildAWFArgsMemoryLimit tests that BuildAWFArgs passes --memory-limit +// when sandbox.agent.memory is configured in the workflow frontmatter + +func TestBuildAWFArgsMemoryLimit(t *testing.T) { + t.Run("includes --memory-limit flag when memory is configured", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Memory: "6g", + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--memory-limit", "Should include --memory-limit flag") + assert.Contains(t, argsStr, "6g", "Should include the memory value") + }) + + t.Run("does not include --memory-limit flag when memory is not configured", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--memory-limit", "Should not include --memory-limit when memory is not configured") + }) + + t.Run("includes correct memory value when multiple sizes configured", func(t *testing.T) { + for _, memory := range []string{"512m", "4g", "8g"} { + t.Run(memory, func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Memory: memory, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--memory-limit", "Should include --memory-limit flag") + assert.Contains(t, argsStr, memory, "Should include the correct memory value") + }) + } + }) +} + +func TestBuildAWFArgsCliProxy(t *testing.T) { + baseWorkflow := func(features map[string]any, tools map[string]any) *WorkflowData { + return &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Features: features, + Tools: tools, + } + } + + t.Run("does not include cli-proxy flags when feature flag is absent", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: baseWorkflow(nil, nil), + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host when feature flag is absent") + assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert when feature flag is absent") + assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") + assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") + }) + + t.Run("includes --difc-proxy-host and --difc-proxy-ca-cert when cli-proxy is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + Features: map[string]any{"cli-proxy": true}, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled") + assert.Contains(t, argsStr, "host.docker.internal:18443", "Should use host.docker.internal:18443 as proxy host") + assert.Contains(t, argsStr, "--difc-proxy-ca-cert", "Should include --difc-proxy-ca-cert") + assert.Contains(t, argsStr, "/tmp/gh-aw/difc-proxy-tls/ca.crt", "Should use the correct CA cert path") + assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") + assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") + }) + + t.Run("uses internal cli proxy host when network isolation is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + NetworkIsolation: true, + }, + }, + Features: map[string]any{"cli-proxy": true}, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled") + assert.Contains(t, argsStr, "awmg-cli-proxy:18443", "Should use internal awf-net CLI proxy address in isolation mode") + assert.NotContains(t, argsStr, "host.docker.internal:18443", "Should not use host.docker.internal in isolation mode") + }) + + t.Run("does not include cli-proxy flags for copilot by default", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + Features: map[string]any{}, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host for copilot by default") + assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert for copilot by default") + }) + + t.Run("does not include deprecated flags even with guard policy configured", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, + }, + Features: map[string]any{"cli-proxy": true}, + Tools: map[string]any{ + "github": map[string]any{ + "min-integrity": "approved", + }, + }, + }, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host") + assert.Contains(t, argsStr, "--difc-proxy-ca-cert", "Should include --difc-proxy-ca-cert") + assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") + assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") + }) + + t.Run("skips all cli-proxy flags when AWF version is too old", func(t *testing.T) { + // Simulate a workflow that pins an AWF version older than AWFCliProxyMinVersion + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Version: "v0.25.16", // older than AWFCliProxyMinVersion v0.25.17 + }, + }, + Features: map[string]any{ + "cli-proxy": true, + }, + Tools: map[string]any{ + "github": map[string]any{ + "min-integrity": "approved", + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host for old AWF") + assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert for old AWF") + assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") + }) +} + +func TestBuildModelsJSONPathExportScript(t *testing.T) { + t.Run("uses tmp path by default", func(t *testing.T) { + assert.Equal(t, `export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"`, buildModelsJSONPathExportScript(false)) + }) + + t.Run("uses runner temp path for arc-dind", func(t *testing.T) { + assert.Equal(t, `export GH_AW_MODELS_JSON_PATH="${RUNNER_TEMP}/gh-aw/models.json"`, buildModelsJSONPathExportScript(true)) + }) +} + +func TestGetAWFCommandPrefixNetworkIsolation(t *testing.T) { + t.Run("returns awf (no sudo) when sudo is false (network isolation mode)", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + NetworkIsolation: true, + }, + }, + } + cmd := GetAWFCommandPrefix(workflowData) + assert.Equal(t, "awf", cmd, "Should return rootless 'awf' when sudo is false (network isolation mode)") + assert.NotContains(t, cmd, "sudo", "Should not contain sudo when sudo is false (network isolation mode)") + }) + + t.Run("returns awf (no sudo) by default in strict security mode", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + NetworkIsolation: false, + }, + }, + } + cmd := GetAWFCommandPrefix(workflowData) + assert.Equal(t, "awf", cmd, "Should return 'awf' (no sudo) in strict security mode even with sudo: true") + }) + + t.Run("returns awf (no sudo) when no sandbox config is set", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + } + cmd := GetAWFCommandPrefix(workflowData) + assert.Equal(t, "awf", cmd, "Should return 'awf' (no sudo) when there is no sandbox config") + }) + + t.Run("returns sudo -E awf when legacy-security is enabled", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + LegacySecurity: true, + }, + }, + } + cmd := GetAWFCommandPrefix(workflowData) + assert.Equal(t, "sudo -E awf", cmd, "Should return 'sudo -E awf' when legacy-security is enabled") + }) + + t.Run("custom command takes precedence over sudo setting", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + NetworkIsolation: true, + Command: "custom-awf", + }, + }, + } + cmd := GetAWFCommandPrefix(workflowData) + assert.Equal(t, "custom-awf", cmd, "Custom command should take precedence over sudo rootless mode") + }) +} + +func TestBuildAWFArgs_LegacySecurityVersionGuard(t *testing.T) { + t.Run("emits --legacy-security when AWF version supports it", func(t *testing.T) { + config := AWFCommandConfig{ + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + LegacySecurity: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Version: "0.27.32", + }, + }, + }, + EngineName: "copilot", + } + args := BuildAWFArgs(config) + assert.Contains(t, args, "--legacy-security", "Should emit --legacy-security for AWF >= v0.27.32") + }) + + t.Run("skips --legacy-security when AWF version is too old", func(t *testing.T) { + config := AWFCommandConfig{ + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + LegacySecurity: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Version: "0.27.30", + }, + }, + }, + EngineName: "copilot", + } + args := BuildAWFArgs(config) + assert.NotContains(t, args, "--legacy-security", "Should NOT emit --legacy-security for AWF < v0.27.32") + // But should still emit --enable-host-access for backward compat + assert.Contains(t, args, "--enable-host-access", "Should still emit --enable-host-access for legacy mode") + }) +} + +func TestBuildAWFCommand_ServicePortsRequireLegacy(t *testing.T) { + t.Run("emits --allow-host-service-ports when legacy-security is enabled", func(t *testing.T) { + config := AWFCommandConfig{ + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + ServicePortExpressions: "${{ job.services.db.ports['5432'] }}", + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + LegacySecurity: true, + }, + }, + }, + EngineName: "copilot", + EngineCommand: "copilot-agent", + } + cmd := BuildAWFCommand(config) + assert.Contains(t, cmd, "--allow-host-service-ports", "Should emit --allow-host-service-ports in legacy mode") + }) + + t.Run("skips --allow-host-service-ports in strict mode", func(t *testing.T) { + config := AWFCommandConfig{ + WorkflowData: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ID: "copilot"}, + ServicePortExpressions: "${{ job.services.db.ports['5432'] }}", + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + }, + }, + }, + EngineName: "copilot", + EngineCommand: "copilot-agent", + } + cmd := BuildAWFCommand(config) + assert.NotContains(t, cmd, "--allow-host-service-ports", "Should NOT emit --allow-host-service-ports in strict mode") + }) +} diff --git a/pkg/workflow/awf_env.go b/pkg/workflow/awf_env.go new file mode 100644 index 00000000000..e04f509f825 --- /dev/null +++ b/pkg/workflow/awf_env.go @@ -0,0 +1,198 @@ +// This file contains AWF environment filtering and max-ai-credits helpers. + +package workflow + +import ( + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/setutil" + "github.com/github/gh-aw/pkg/workflow/compilerenv" +) + +// applyDefaultMaxAICreditsEnvToMap adds the runtime max-ai-credits GitHub Actions expression +// to env when no compile-time max-ai-credits is configured. +// +// This keeps the organization/repository variable override behavior while allowing AWF run: +// scripts to read GH_AW_MAX_AI_CREDITS from step env instead of embedding ${{ vars.* }} +// directly in run blocks. +func applyDefaultMaxAICreditsEnvToMap(env map[string]string, workflowData *WorkflowData) { + if env == nil { + return + } + if workflowData != nil && workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxAICredits != 0 { + return + } + if workflowData != nil && workflowData.IsEvalsRun { + env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultEvalsMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)) + return + } + if workflowData != nil && workflowData.IsDetectionRun { + env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultDetectionMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)) + return + } + env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultMaxAICreditsExpression(strconv.FormatInt(constants.DefaultMaxAICredits, 10)) +} + +// injectMaxAICreditsExpression inserts "maxAiCredits":expr into the apiProxy +// JSON object of awfConfigJSON directly after the "maxRuns" field value. +// +// expr is a shell variable reference such as "${GH_AW_MAX_AI_CREDITS}". The +// caller emits a local export line before the printf command that assigns the +// GitHub Actions runtime expression to that variable, so the ${{ }} expression +// lives on one clean, dedicated line rather than being embedded inside the JSON. +// +// shellEscapeArgWithVarPreserved is then used to double-quote the JSON arg while +// preserving the ${varName} reference for bash expansion and escaping bare $ signs +// (e.g. "$schema" → "\$schema"). +func injectMaxAICreditsExpression(awfConfigJSON string, expr string) string { + const maxRunsKey = `"maxRuns":` + idx := strings.Index(awfConfigJSON, maxRunsKey) + if idx == -1 { + awfHelpersLog.Print("Warning: could not find maxRuns in AWF config JSON; maxAiCredits expression not injected") + return awfConfigJSON + } + // Scan past the integer value of maxRuns. + valueEnd := idx + len(maxRunsKey) + for valueEnd < len(awfConfigJSON) && awfConfigJSON[valueEnd] >= '0' && awfConfigJSON[valueEnd] <= '9' { + valueEnd++ + } + return awfConfigJSON[:valueEnd] + `,"maxAiCredits":` + expr + awfConfigJSON[valueEnd:] +} + +// ComputeAWFExcludeEnvVarNames returns the list of environment variable names that must be +// excluded from the agent container's visible environment via AWF's --exclude-env flag. +// +// Env var names are included when their step-env values contain a ${{ secrets.* }} reference +// OR a ${{ needs.JOB.outputs.OUTPUT }} job-output expression (which commonly carries +// ephemeral tokens such as GitHub App installation tokens). Non-secret static vars +// (e.g. GH_DEBUG: "1" in mcp-scripts) are never excluded. +// +// Parameters: +// - workflowData: the workflow being compiled +// - coreSecretVarNames: engine-specific fixed secret env var names (e.g. ["COPILOT_GITHUB_TOKEN"]) +// +// The function augments coreSecretVarNames with: +// - MCP_GATEWAY_API_KEY when MCP servers are present +// - GITHUB_MCP_SERVER_TOKEN when the GitHub tool is present +// - HTTP MCP header secret var names (values always contain ${{ secrets.* }}) +// - mcp-scripts env var names whose values contain ${{ secrets.* }} or a job-output expression +// - engine.env var names whose values contain ${{ secrets.* }} or a job-output expression +// - agent.env var names whose values contain ${{ secrets.* }} or a job-output expression +// - names listed in the frontmatter excluded-env field (unconditionally) +func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames []string) []string { + seen := make(map[string]struct { + }) + var names []string + + addUnique := func(name string) { + if !setutil.Contains(seen, name) { + seen[name] = struct { + }{} + names = append(names, name) + } + } + + // Core secret vars for this engine (always contain secret references). + for _, name := range coreSecretVarNames { + addUnique(name) + } + + // MCP gateway API key is always a secret when MCP servers are present. + if HasMCPServers(workflowData) { + addUnique("MCP_GATEWAY_API_KEY") + } + + // GitHub MCP server token is always a secret when the GitHub tool is present. + if hasGitHubTool(workflowData.ParsedTools) { + addUnique("GITHUB_MCP_SERVER_TOKEN") + } + + // HTTP MCP header secrets: values are always ${{ secrets.* }} references. + for varName := range collectHTTPMCPHeaderSecrets(workflowData.Tools) { + addUnique(varName) + } + + // mcp-scripts env vars: only add those whose configured values contain a secret reference + // or a job-output expression (e.g. ${{ needs.fetch_token.outputs.token }}). + // (Non-secret vars like GH_DEBUG: "1" must NOT be excluded.) + if workflowData.MCPScripts != nil { + for _, toolConfig := range workflowData.MCPScripts.Tools { + for envName, envValue := range toolConfig.Env { + if strings.Contains(envValue, "${{ secrets.") || ContainsJobOutputExpr(envValue) { + addUnique(envName) + } + } + } + } + + // engine.env vars that contain a secret reference or a job-output expression. + if workflowData.EngineConfig != nil { + for varName, varValue := range workflowData.EngineConfig.Env { + if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { + addUnique(varName) + } + } + } + + // agent.env vars that contain a secret reference or a job-output expression. + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil { + for varName, varValue := range agentConfig.Env { + if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { + addUnique(varName) + } + } + } + + // GH_TOKEN when GitHub mode is gh-proxy: the token is passed in the AWF step env for the + // host difc-proxy but must be excluded from the agent container. + if isGitHubCLIModeEnabled(workflowData) { + addUnique("GH_TOKEN") + } + + // Actions OIDC request credentials must never be visible to the sandboxed AWF agent. + // The runner-owned gateway forwards them only for HTTP MCP github-oidc authentication. + addUnique("ACTIONS_ID_TOKEN_REQUEST_URL") + addUnique("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if enclavesEnabled(workflowData) { + addUnique(enclaveMCPCapabilityEnv) + addUnique(enclaveMCPGatewayContainerEnv) + addUnique(enclaveMCPGatewayEndpointEnv) + addUnique(enclaveMCPGatewayIdentityEnv) + addUnique(enclaveMCPReadinessTimeoutEnv) + } + + // Explicitly excluded env vars from the frontmatter excluded-env field. + // These are always excluded regardless of their value content. + for _, name := range workflowData.ExcludedEnv { + addUnique(name) + } + + awfHelpersLog.Printf("Computed %d AWF env vars to exclude", len(names)) + return names +} + +// addCliProxyGHTokenToEnv adds GH_TOKEN to the AWF step environment when GitHub +// mode is gh-proxy. The token is NOT used by AWF or its cli-proxy +// sidecar directly — the host difc-proxy (started by start_cli_proxy.sh) already +// has it. However, --env-all passes all step env vars into the agent container, +// so we explicitly set GH_TOKEN here to ensure --exclude-env GH_TOKEN can +// reliably strip it regardless of how the token enters the environment. +// The token is excluded from the agent container via --exclude-env GH_TOKEN, so only +// inject it when the effective AWF version supports both cli-proxy flags and +// --exclude-env. +// +// #nosec G101 -- This is NOT a hardcoded credential. It is a GitHub Actions expression +// template that is resolved at runtime by the GitHub Actions runner. +func addCliProxyGHTokenToEnv(env map[string]string, workflowData *WorkflowData) { + firewallConfig := getFirewallConfig(workflowData) + if isGitHubCLIModeEnabled(workflowData) && + isFirewallEnabled(workflowData) && + awfSupportsCliProxy(firewallConfig) && + awfSupportsExcludeEnv(firewallConfig) { + env["GH_TOKEN"] = "${{ secrets.GH_AW_GITHUB_TOKEN || github.token }}" + awfHelpersLog.Print("Added GH_TOKEN to env for CLI proxy (excluded from agent container)") + } +} diff --git a/pkg/workflow/awf_env_test.go b/pkg/workflow/awf_env_test.go new file mode 100644 index 00000000000..29b69d81bba --- /dev/null +++ b/pkg/workflow/awf_env_test.go @@ -0,0 +1,324 @@ +//go:build !integration + +package workflow + +import ( + "strconv" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/workflow/compilerenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInjectMaxAICreditsExpressionWithoutMaxRunsLeavesJSONUnchanged(t *testing.T) { + configJSON := `{"apiProxy":{"enabled":true}}` + + got := injectMaxAICreditsExpression(configJSON, "${GH_AW_MAX_AI_CREDITS}") + + if got != configJSON { + t.Fatalf("expected config JSON to be unchanged, got %q", got) + } +} + +func TestApplyDefaultMaxAICreditsEnvToMapHandlesNilMap(t *testing.T) { + assert.NotPanics(t, func() { + applyDefaultMaxAICreditsEnvToMap(nil, nil) + }) +} + +func TestApplyDefaultMaxAICreditsEnvToMap(t *testing.T) { + t.Run("sets default agent expression when max-ai-credits is unset", func(t *testing.T) { + env := map[string]string{} + applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + }) + assert.Equal(t, compilerenv.BuildDefaultMaxAICreditsExpression(strconv.FormatInt(constants.DefaultMaxAICredits, 10)), env[awfMaxAICreditsVarName]) + }) + + t.Run("sets default detection expression for detection runs", func(t *testing.T) { + env := map[string]string{} + applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ + IsDetectionRun: true, + EngineConfig: &EngineConfig{ID: "copilot"}, + }) + assert.Equal(t, compilerenv.BuildDefaultDetectionMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)), env[awfMaxAICreditsVarName]) + }) + + t.Run("sets default evals expression for evals runs", func(t *testing.T) { + env := map[string]string{} + applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ + IsEvalsRun: true, + EngineConfig: &EngineConfig{ID: "copilot"}, + }) + assert.Equal(t, compilerenv.BuildDefaultEvalsMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)), env[awfMaxAICreditsVarName]) + }) + + t.Run("does not set expression when max-ai-credits is configured", func(t *testing.T) { + env := map[string]string{} + applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + MaxAICredits: 777, + }, + }) + _, exists := env[awfMaxAICreditsVarName] + assert.False(t, exists) + }) +} + +// TestComputeAWFExcludeEnvVarNames verifies that engine.env vars whose values contain +// ${{ secrets.* }} are automatically included in the --exclude-env list, and that +// non-secret engine.env vars and plain-value core secrets are handled correctly. +func TestComputeAWFExcludeEnvVarNames(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + coreSecretVarNames []string + want []string + notWant []string + }{ + { + name: "engine.env secret var is auto-excluded", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "GOOGLE_API_KEY": "${{ secrets.SOME_KEY }}", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"GOOGLE_API_KEY"}, + }, + { + name: "engine.env non-secret var is not excluded", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "DEBUG": "true", + "LOG_LEVEL": "info", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{}, + notWant: []string{"DEBUG", "LOG_LEVEL"}, + }, + { + name: "engine.env mixes secret and non-secret vars: only secrets excluded", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "GOOGLE_API_KEY": "${{ secrets.SOME_KEY }}", + "LOG_LEVEL": "debug", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"GOOGLE_API_KEY"}, + notWant: []string{"LOG_LEVEL"}, + }, + { + name: "engine.env secret combined with core secret vars", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_API_KEY": "${{ secrets.CUSTOM_KEY }}", + }, + }, + }, + coreSecretVarNames: []string{"GEMINI_API_KEY"}, + want: []string{"GEMINI_API_KEY", "CUSTOM_API_KEY"}, + }, + { + name: "engine.env secret embedded in a larger string is excluded", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "AUTH_HEADER": "Bearer ${{ secrets.TOKEN }}", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"AUTH_HEADER"}, + }, + { + name: "nil engine config produces no exclusions beyond core secrets", + workflowData: &WorkflowData{ + EngineConfig: nil, + }, + coreSecretVarNames: []string{"COPILOT_GITHUB_TOKEN"}, + want: []string{"COPILOT_GITHUB_TOKEN"}, + }, + // --- job-output expression tests --- + { + name: "mcp-scripts env var with job-output value is excluded", + workflowData: &WorkflowData{ + MCPScripts: &MCPScriptsConfig{ + Tools: map[string]*MCPScriptToolConfig{ + "example": { + Env: map[string]string{ + "GH_TOKEN": "${{ needs.fetch_token.outputs.token }}", + }, + }, + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"GH_TOKEN"}, + }, + { + name: "mcp-scripts env var with static value is not excluded", + workflowData: &WorkflowData{ + MCPScripts: &MCPScriptsConfig{ + Tools: map[string]*MCPScriptToolConfig{ + "example": { + Env: map[string]string{ + "GH_DEBUG": "1", + }, + }, + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{}, + notWant: []string{"GH_DEBUG"}, + }, + { + name: "engine.env var with job-output value is excluded", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "GITHUB_TOKEN": "${{ needs.token_job.outputs.github_token }}", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"GITHUB_TOKEN"}, + }, + { + name: "engine.env non-credential job-output var is excluded (consistent with secret behavior)", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "REPO_URL": "${{ needs.setup.outputs.repo_url }}", + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"REPO_URL"}, + }, + { + name: "mcp-scripts env var with job-output value mixed with secret: both excluded", + workflowData: &WorkflowData{ + MCPScripts: &MCPScriptsConfig{ + Tools: map[string]*MCPScriptToolConfig{ + "tool1": { + Env: map[string]string{ + "GH_TOKEN": "${{ needs.fetch_token.outputs.token }}", + "API_KEY": "${{ secrets.API_KEY }}", + "STATIC_HOST": "https://api.example.com", + }, + }, + }, + }, + }, + coreSecretVarNames: []string{}, + want: []string{"GH_TOKEN", "API_KEY"}, + notWant: []string{"STATIC_HOST"}, + }, + // --- excluded-env frontmatter field tests --- + { + name: "excluded-env frontmatter field adds names unconditionally", + workflowData: &WorkflowData{ + ExcludedEnv: []string{"MY_CUSTOM_TOKEN", "ANOTHER_SECRET"}, + }, + coreSecretVarNames: []string{}, + want: []string{"MY_CUSTOM_TOKEN", "ANOTHER_SECRET"}, + }, + { + name: "excluded-env combined with core secrets: all excluded", + workflowData: &WorkflowData{ + ExcludedEnv: []string{"CUSTOM_PAT"}, + }, + coreSecretVarNames: []string{"COPILOT_GITHUB_TOKEN"}, + want: []string{"COPILOT_GITHUB_TOKEN", "CUSTOM_PAT"}, + }, + { + name: "excluded-env deduplicates with auto-detected secrets", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "MY_TOKEN": "${{ secrets.MY_TOKEN }}", + }, + }, + ExcludedEnv: []string{"MY_TOKEN"}, + }, + coreSecretVarNames: []string{}, + want: []string{"MY_TOKEN"}, + }, + { + name: "always excludes actions oidc env vars from awf agent", + workflowData: &WorkflowData{}, + coreSecretVarNames: []string{}, + want: []string{ + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + }, + }, + { + name: "empty excluded-env has no effect", + workflowData: &WorkflowData{ + ExcludedEnv: []string{}, + }, + coreSecretVarNames: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeAWFExcludeEnvVarNames(tt.workflowData, tt.coreSecretVarNames) + for _, name := range tt.want { + assert.Contains(t, got, name, "expected %q in exclude list", name) + } + for _, name := range tt.notWant { + assert.NotContains(t, got, name, "expected %q to be absent from exclude list", name) + } + }) + } +} + +// TestMainAgentRunUsesStandardCreditsExpressionNotDetectionExpression verifies that +// a standard (non-detection) main-agent run emits the main-agent credits expression +// (vars.GH_AW_DEFAULT_MAX_AI_CREDITS) and not the detection-specific one, so a future +// refactor that accidentally sets IsDetectionRun on main-agent data will be caught. +func TestMainAgentRunUsesStandardCreditsExpressionNotDetectionExpression(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "claude", + // MaxAICredits is zero (not set in frontmatter) to trigger runtime expression injection. + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + // IsDetectionRun is false by default — this is a main-agent run. + } + + engine := NewClaudeEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + require.NotEmpty(t, steps, "should produce execution steps") + + stepContent := strings.Join(steps[0], "\n") + + assert.Contains(t, stepContent, "vars.GH_AW_DEFAULT_MAX_AI_CREDITS", + "main-agent run should use standard credits expression") + assert.NotContains(t, stepContent, "vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS", + "main-agent run must not use detection credits expression") +} + +// TestGetAWFCommandPrefixNetworkIsolation tests that GetAWFCommandPrefix returns the correct +// command based on security mode: strict (default, no sudo) or legacy (sudo -E awf). diff --git a/pkg/workflow/awf_feature_flags.go b/pkg/workflow/awf_feature_flags.go new file mode 100644 index 00000000000..4f4a4ccd175 --- /dev/null +++ b/pkg/workflow/awf_feature_flags.go @@ -0,0 +1,85 @@ +// This file contains AWF version-gated capability helpers. + +package workflow + +import "github.com/github/gh-aw/pkg/constants" + +// awfSupportsExcludeEnv returns true when the effective AWF version supports --exclude-env +// (introduced in AWF v0.25.3). +func awfSupportsExcludeEnv(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFExcludeEnvMinVersion) +} + +// awfVersionAtLeast returns true when the effective AWF version is at or above minVersion. +// +// If firewallConfig has no version set, DefaultFirewallVersion is used. "latest" always +// returns true. Non-semver strings (e.g. branch names) return false (conservative). +func awfVersionAtLeast(firewallConfig *FirewallConfig, minVersion constants.Version) bool { + var versionStr string + if firewallConfig != nil && firewallConfig.Version != "" { + versionStr = firewallConfig.Version + } + return versionAtLeast(versionStr, string(constants.DefaultFirewallVersion), string(minVersion)) +} + +// awfSupportsCliProxy returns true when the effective AWF version supports --difc-proxy-host +// and --difc-proxy-ca-cert (introduced in AWF v0.26.0). +func awfSupportsCliProxy(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFCliProxyMinVersion) +} + +// awfSupportsAllowHostPorts returns true when the effective AWF version supports +// --allow-host-ports. +func awfSupportsAllowHostPorts(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFAllowHostPortsMinVersion) +} + +// awfSupportsDockerHostPathPrefix returns true when the effective AWF version supports +// --docker-host-path-prefix. +func awfSupportsDockerHostPathPrefix(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFDockerHostPathPrefixMinVersion) +} + +// awfSupportsTokenSteering returns true when the effective AWF version supports +// apiProxy.enableTokenSteering. +func awfSupportsTokenSteering(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFTokenSteeringMinVersion) +} + +// awfSupportsChrootConfig returns true when the effective AWF version supports +// chroot.binariesSourcePath and chroot.identity.* in the config file (AWF v0.27.1+). +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) +} + +// 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) +} + +// awfSupportsDefaultAiCreditsPricing returns true when apiProxy.defaultAiCreditsPricing +// survives AWF config resolution and reaches the api-proxy container. +func awfSupportsDefaultAiCreditsPricing(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFDefaultAiCreditsPricingMinVersion) +} + +// awfSupportsAPIProxyProviders returns true when the effective AWF version supports +// apiProxy.providers in awf-config.json. +func awfSupportsAPIProxyProviders(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFAPIProxyProvidersMinVersion) +} + +// awfSupportsBoundedQueries returns true when the effective AWF version supports +// the boundedQueries section in awf-config.json. +func awfSupportsBoundedQueries(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFBoundedQueriesMinVersion) +} diff --git a/pkg/workflow/awf_feature_flags_test.go b/pkg/workflow/awf_feature_flags_test.go new file mode 100644 index 00000000000..d84a6d545f5 --- /dev/null +++ b/pkg/workflow/awf_feature_flags_test.go @@ -0,0 +1,363 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAWFSupportsExcludeEnv(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config (default version) supports --exclude-env", + firewallConfig: nil, + want: true, + }, + { + name: "empty version (default) supports --exclude-env", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "v0.25.3 supports --exclude-env", + firewallConfig: &FirewallConfig{Version: "v0.25.3"}, + want: true, + }, + { + name: "v0.26.0 supports --exclude-env", + firewallConfig: &FirewallConfig{Version: "v0.26.0"}, + want: true, + }, + { + name: "v0.27.0 supports --exclude-env", + firewallConfig: &FirewallConfig{Version: "v0.27.0"}, + want: true, + }, + { + name: "latest supports --exclude-env", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.25.0 does not support --exclude-env", + firewallConfig: &FirewallConfig{Version: "v0.25.0"}, + want: false, + }, + { + name: "v0.1.0 does not support --exclude-env", + firewallConfig: &FirewallConfig{Version: "v0.1.0"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsExcludeEnv(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsExcludeEnv result") + }) + } +} + +func TestAWFSupportsCliProxy(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (uses default version)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (uses default version)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.25.17 supports CLI proxy flags (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.25.17"}, + want: true, + }, + { + name: "v0.26.0 supports CLI proxy flags", + firewallConfig: &FirewallConfig{Version: "v0.26.0"}, + want: true, + }, + { + name: "v0.27.0 supports CLI proxy flags", + firewallConfig: &FirewallConfig{Version: "v0.27.0"}, + want: true, + }, + { + name: "v0.25.16 does not support CLI proxy flags", + firewallConfig: &FirewallConfig{Version: "v0.25.16"}, + want: false, + }, + { + name: "v0.25.14 does not support CLI proxy flags", + firewallConfig: &FirewallConfig{Version: "v0.25.14"}, + want: false, + }, + { + name: "v0.1.0 does not support CLI proxy flags", + firewallConfig: &FirewallConfig{Version: "v0.1.0"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsCliProxy(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsCliProxy result") + }) + } +} + +// TestAWFSupportsAllowHostPorts tests the awfSupportsAllowHostPorts version gate function. + +func TestAWFSupportsAllowHostPorts(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (uses default version)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (uses default version)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.25.24 supports --allow-host-ports (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.25.24"}, + want: true, + }, + { + name: "v0.26.0 supports --allow-host-ports", + firewallConfig: &FirewallConfig{Version: "v0.26.0"}, + want: true, + }, + { + name: "v0.25.23 does not support --allow-host-ports", + firewallConfig: &FirewallConfig{Version: "v0.25.23"}, + want: false, + }, + { + name: "v0.1.0 does not support --allow-host-ports", + firewallConfig: &FirewallConfig{Version: "v0.1.0"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsAllowHostPorts(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsAllowHostPorts result") + }) + } +} + +// TestAWFSupportsDockerHostPathPrefix tests the awfSupportsDockerHostPathPrefix version gate. + +func TestAWFSupportsDockerHostPathPrefix(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (uses default version)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (uses default version)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.25.43 supports --docker-host-path-prefix (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.25.43"}, + want: true, + }, + { + name: "v0.25.42 does not support --docker-host-path-prefix", + firewallConfig: &FirewallConfig{Version: "v0.25.42"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsDockerHostPathPrefix(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsDockerHostPathPrefix result") + }) + } +} + +func TestAWFSupportsTokenSteering(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (uses default version)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (uses default version)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.25.44 supports token steering (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.25.44"}, + want: true, + }, + { + name: "v0.25.43 does not support token steering", + firewallConfig: &FirewallConfig{Version: "v0.25.43"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsTokenSteering(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsTokenSteering result") + }) + } +} + +// TestAWFSupportsChrootConfig tests the awfSupportsChrootConfig version gate. + +func TestAWFSupportsChrootConfig(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (uses default version)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (uses default version)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.27.1 supports chroot config (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.27.1"}, + want: true, + }, + { + name: "v0.27.0 does not support chroot config", + firewallConfig: &FirewallConfig{Version: "v0.27.0"}, + want: false, + }, + { + name: "v0.25.44 (old) does not support chroot config", + firewallConfig: &FirewallConfig{Version: "v0.25.44"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsChrootConfig(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsChrootConfig result") + }) + } +} + +// TestAWFSupportsAPIProxyProviders tests the awfSupportsAPIProxyProviders version gate. + +func TestAWFSupportsAPIProxyProviders(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns true (default version v0.27.43 meets minimum)", + firewallConfig: nil, + want: true, + }, + { + name: "empty version returns true (default version v0.27.43 meets minimum)", + firewallConfig: &FirewallConfig{}, + want: true, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.27.43 supports apiProxy.providers (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.27.43"}, + want: true, + }, + { + name: "v0.27.42 does not support apiProxy.providers (schema not present)", + firewallConfig: &FirewallConfig{Version: "v0.27.42"}, + want: false, + }, + { + name: "v0.27.41 does not support apiProxy.providers", + firewallConfig: &FirewallConfig{Version: "v0.27.41"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsAPIProxyProviders(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsAPIProxyProviders result") + }) + } +} + +// TestBuildAWFCommand_IncludesChrootInjectScript verifies that BuildAWFCommand +// includes the chroot injection script in the generated run step when the AWF +// version supports it. diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index b20b363a2df..bd5733e5776 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -1,38 +1,17 @@ -// This file provides helper functions for AWF (Agentic Workflow Firewall) integration. +// This file keeps shared AWF (Agentic Workflow Firewall) scaffolding used by +// the focused AWF helper modules in this package. // -// AWF is the network firewall/sandbox used by gh-aw to control network egress for -// AI agent execution. This file consolidates common AWF logic that was previously -// duplicated across multiple engine implementations (Copilot, Claude, Codex). -// -// # Key Functions -// -// AWF Command Building: -// - BuildAWFCommand() - Builds complete AWF command with all arguments -// - BuildAWFArgs() - Constructs common AWF arguments from configuration -// - GetAWFCommandPrefix() - Determines AWF command (custom vs standard) -// - WrapCommandInShell() - Wraps engine command in shell for AWF execution -// -// AWF Configuration: -// - GetAWFDomains() - Combines allowed/blocked domains from various sources -// - GetSSLBumpArgs() - Returns SSL bump configuration arguments -// - GetAWFImageTag() - Returns pinned AWF image tag -// -// These functions extract shared AWF patterns from engine implementations, -// providing a consistent and maintainable approach to AWF integration. +// Command assembly, environment filtering, ARC/DinD handling, and feature gates +// live in awf_command_builder.go, awf_env.go, awf_arc_dind.go, and +// awf_feature_flags.go respectively. package workflow import ( "encoding/json" "fmt" - "sort" - "strconv" - "strings" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/setutil" - "github.com/github/gh-aw/pkg/workflow/compilerenv" ) var awfHelpersLog = logger.New("workflow:awf_helpers") @@ -135,65 +114,6 @@ func buildModelsJSONPathExportScript(isArcDind bool) string { return fmt.Sprintf(`export GH_AW_MODELS_JSON_PATH="%s"`, modelsJSONPathExpr) } -func rewriteArcDindPath(path string) string { - return strings.ReplaceAll(path, constants.TmpGhAwDir, awfArcDindRootPathExpr) -} - -func rewriteArcDindEngineCommand(command string) string { - rewritten := rewriteArcDindPath(command) - return fmt.Sprintf("export HOME=%s\n%s", awfArcDindHomePathExpr, rewritten) -} - -// applyDefaultMaxAICreditsEnvToMap adds the runtime max-ai-credits GitHub Actions expression -// to env when no compile-time max-ai-credits is configured. -// -// This keeps the organization/repository variable override behavior while allowing AWF run: -// scripts to read GH_AW_MAX_AI_CREDITS from step env instead of embedding ${{ vars.* }} -// directly in run blocks. -func applyDefaultMaxAICreditsEnvToMap(env map[string]string, workflowData *WorkflowData) { - if env == nil { - return - } - if workflowData != nil && workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxAICredits != 0 { - return - } - if workflowData != nil && workflowData.IsEvalsRun { - env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultEvalsMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)) - return - } - if workflowData != nil && workflowData.IsDetectionRun { - env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultDetectionMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)) - return - } - env[awfMaxAICreditsVarName] = compilerenv.BuildDefaultMaxAICreditsExpression(strconv.FormatInt(constants.DefaultMaxAICredits, 10)) -} - -// injectMaxAICreditsExpression inserts "maxAiCredits":expr into the apiProxy -// JSON object of awfConfigJSON directly after the "maxRuns" field value. -// -// expr is a shell variable reference such as "${GH_AW_MAX_AI_CREDITS}". The -// caller emits a local export line before the printf command that assigns the -// GitHub Actions runtime expression to that variable, so the ${{ }} expression -// lives on one clean, dedicated line rather than being embedded inside the JSON. -// -// shellEscapeArgWithVarPreserved is then used to double-quote the JSON arg while -// preserving the ${varName} reference for bash expansion and escaping bare $ signs -// (e.g. "$schema" → "\$schema"). -func injectMaxAICreditsExpression(awfConfigJSON string, expr string) string { - const maxRunsKey = `"maxRuns":` - idx := strings.Index(awfConfigJSON, maxRunsKey) - if idx == -1 { - awfHelpersLog.Print("Warning: could not find maxRuns in AWF config JSON; maxAiCredits expression not injected") - return awfConfigJSON - } - // Scan past the integer value of maxRuns. - valueEnd := idx + len(maxRunsKey) - for valueEnd < len(awfConfigJSON) && awfConfigJSON[valueEnd] >= '0' && awfConfigJSON[valueEnd] <= '9' { - valueEnd++ - } - return awfConfigJSON[:valueEnd] + `,"maxAiCredits":` + expr + awfConfigJSON[valueEnd:] -} - func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) { ecosystemDomains := getLoadedEcosystemDomains() ecosystemMap := make(map[string][]string, safeAllocationCapacity(len(ecosystemDomains), len(compoundEcosystems))) @@ -216,940 +136,3 @@ func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) { return fmt.Sprintf(`GH_AW_ECOSYSTEM_MAP_JSON=%s node "${RUNNER_TEMP}/gh-aw/actions/update_network_allowed.cjs"`, shellEscapeArg(string(ecosystemJSON))), nil } - -// BuildAWFCommand builds a complete AWF command with all arguments. -// This consolidates the AWF command building logic that was duplicated across -// Copilot, Claude, and Codex engines. -// -// Parameters: -// - config: AWF command configuration -// -// Returns: -// - string: Complete AWF command with arguments and wrapped engine command -func BuildAWFCommand(config AWFCommandConfig) string { - awfHelpersLog.Printf("Building AWF command for engine: %s", config.EngineName) - isArcDind := isArcDindTopology(config.WorkflowData) - - // Get AWF command prefix (custom or standard) - awfCommand := GetAWFCommandPrefix(config.WorkflowData) - - // Build AWF arguments. The returned list contains only args that are safe to pass - // through shellJoinArgs. Expandable-var args (--container-workdir "${GITHUB_WORKSPACE}" - // and --mount "${RUNNER_TEMP}/...") are appended raw below so that shell variable - // expansion is not suppressed by single-quoting. - awfArgs := BuildAWFArgs(config) - firewallConfig := getFirewallConfig(config.WorkflowData) - - // Auto-detect ARC/DinD split daemon topology at runtime: probe DOCKER_HOST for a - // tcp:// scheme and pass it through to AWF via --docker-host. - // All behaviors avoid requiring workflow-authored sandbox.agent.args for standard ARC DinD setups. - // When AWF also supports chroot config (v0.27.1+), the Python patch body is embedded inside - // the same if-block so the script only contains one DOCKER_HOST condition check. - arcDindPrefixProbe := "" - arcDindDockerHostProbe := fmt.Sprintf(`%s="" -if [[ "${DOCKER_HOST:-}" =~ %s ]]; then - %s="${DOCKER_HOST}" -fi`, - awfDockerHostVarName, - awfArcDindDockerHostRegex, - awfDockerHostVarName, - ) - arcDindDockerHostRef := fmt.Sprintf("${%s:+--docker-host \"$%s\"}", awfDockerHostVarName, awfDockerHostVarName) - if awfSupportsDockerHostPathPrefix(firewallConfig) { - chrootPatchBody := "" - if awfSupportsChrootConfig(firewallConfig) { - if config.WorkflowData != nil && config.WorkflowData.IsDetectionRun { - chrootPatchBody = "\n" + buildArcDindChrootConfigPatchBodyBash() - } else { - chrootPatchBody = "\n" + buildArcDindChrootConfigPatchBody() - } - } - // NOTE: --docker-host-path-prefix is intentionally NOT passed. With sysroot-stage - // active, all bind-mount source paths are on the shared work volume and visible to - // the Docker daemon without translation. The prefix caused AWF to translate - // GITHUB_WORKSPACE to a non-existent path, resulting in an empty workspace (gh-aw#34896). - // The probe block is preserved for the chroot config patch which still requires the - // DOCKER_HOST guard. - if chrootPatchBody != "" { - arcDindPrefixProbe = fmt.Sprintf(`if [[ "${DOCKER_HOST:-}" =~ %s ]]; then%s -fi`, - awfArcDindDockerHostRegex, - chrootPatchBody) - } - } - toolCacheMountProbe := fmt.Sprintf(`%s="" -GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" -if [ -d "$GH_AW_TOOL_CACHE" ]; then - if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then - %s="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" - fi -fi`, - awfToolCacheMountVarName, - awfToolCacheMountVarName, - ) - toolCacheMountRef := fmt.Sprintf("${%s:+--mount \"$%s\"}", awfToolCacheMountVarName, awfToolCacheMountVarName) - - // Build the expandable args string for args that need shell variable expansion. - // These MUST be appended as raw (unescaped) strings because single-quoting would - // prevent the runner's shell from expanding ${GITHUB_WORKSPACE} and ${RUNNER_TEMP}. - ghAwDir := constants.GhAwRootDirShell - expandableArgs := fmt.Sprintf( - `--container-workdir "${GITHUB_WORKSPACE}" --mount "%s:%s:ro" --mount "%s:/host%s:ro"`, - ghAwDir, ghAwDir, ghAwDir, ghAwDir, - ) - if isArcDind { - expandableArgs += fmt.Sprintf( - ` --mount "%s:%s:rw" --mount "%s:%s:rw"`, - awfArcDindHomePathExpr, awfArcDindHomePathExpr, - awfArcDindRootPathExpr+"/sandbox/agent", awfArcDindRootPathExpr+"/sandbox/agent", - ) - // Explicitly mount the workspace so AWF can see it without path-prefix translation. - // GITHUB_WORKSPACE is on the shared work volume, so the Docker daemon can access it. - expandableArgs += ` --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw"` - // Pre-create the rw mount source directories. AWF validates that mount source - // paths exist before starting containers, so these must be created on the host - // before the AWF invocation. The parent ${RUNNER_TEMP}/gh-aw/ already exists - // (created by actions/setup), but the subdirectories may not. - arcDindDockerHostProbe += fmt.Sprintf("\nmkdir -p \"%s\" \"%s\"", - awfArcDindHomePathExpr, - awfArcDindRootPathExpr+"/sandbox/agent", - ) - // Copy prompt files to daemon-visible path. On ARC/DinD, /tmp/gh-aw/ is NOT - // accessible to the Docker daemon. The activation job writes prompts to - // /tmp/gh-aw/aw-prompts/, so we copy them to ${RUNNER_TEMP}/gh-aw/aw-prompts/. - arcDindDockerHostProbe += fmt.Sprintf("\nif [ -d /tmp/gh-aw/aw-prompts ]; then cp -a /tmp/gh-aw/aw-prompts \"%s/aw-prompts\"; fi", - awfArcDindRootPathExpr, - ) - } - - // Generate a JSON config file and reference it via --config "${RUNNER_TEMP}/gh-aw/awf-config.json". - // This replaces several verbose CLI flags (--allow-domains, --enable-api-proxy, --image-tag, - // API targets) with a structured JSON file that is easier to audit and extend. - // - // The config file is written at runtime (inside the run: step) immediately before the AWF - // invocation, using printf to a fixed path inside the pre-existing ${RUNNER_TEMP}/gh-aw/ - // directory that is already set up by actions/setup. - var configFileSetup string - awfConfigJSON, err := BuildAWFConfigJSON(config) - if err != nil { - awfHelpersLog.Printf("Warning: failed to build AWF config JSON: %v", err) - } else { - // When max-ai-credits is not set by frontmatter/imports, export a local shell - // variable (GH_AW_MAX_AI_CREDITS) holding a GitHub Actions runtime expression, - // then inject a reference to that variable (${GH_AW_MAX_AI_CREDITS}) into the - // "maxAiCredits" field of the apiProxy JSON object. GitHub Actions evaluates - // the ${{ }} expression before the shell runs, so the variable is set to the - // resolved integer by the time printf writes the config file. - // - // Standard agent runs use vars.GH_AW_DEFAULT_MAX_AI_CREDITS with built-in - // fallback 1000. Threat-detection runs use - // vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS with built-in fallback 400. - // Evals runs use vars.GH_AW_DEFAULT_EVALS_MAX_AI_CREDITS with built-in - // fallback 400 to align with detection budgets. - // EngineConfig.MaxAICredits is 0 when no compile-time value was set - // (neither frontmatter nor detection-engine config provided one). - // In that case, emit a runtime expression that lets the org variable - // or the built-in default resolve the budget at action run time. - // For detection runs, use the detection-specific variable/fallback; - // for standard agent runs, use the main-agent variable/fallback. - var maxAICreditsExportLine string - if config.WorkflowData == nil || config.WorkflowData.EngineConfig == nil || config.WorkflowData.EngineConfig.MaxAICredits == 0 { - defaultMaxAICredits := strconv.FormatInt(constants.DefaultMaxAICredits, 10) - if config.WorkflowData != nil { - switch { - case config.WorkflowData.IsEvalsRun: - defaultMaxAICredits = strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10) - case config.WorkflowData.IsDetectionRun: - defaultMaxAICredits = strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10) - } - } - awfConfigJSON = injectMaxAICreditsExpression(awfConfigJSON, fmt.Sprintf("${%s}", awfMaxAICreditsVarName)) - if config.ResolveMaxAICreditsFromEnv { - maxAICreditsExportLine = fmt.Sprintf(`%s="${%s:-%s}"`, awfMaxAICreditsVarName, awfMaxAICreditsVarName, defaultMaxAICredits) - } else { - expr := compilerenv.BuildDefaultMaxAICreditsExpression(defaultMaxAICredits) - if config.WorkflowData != nil { - switch { - case config.WorkflowData.IsEvalsRun: - expr = compilerenv.BuildDefaultEvalsMaxAICreditsExpression(defaultMaxAICredits) - case config.WorkflowData.IsDetectionRun: - expr = compilerenv.BuildDefaultDetectionMaxAICreditsExpression(defaultMaxAICredits) - } - } - maxAICreditsExportLine = fmt.Sprintf(`%s="%s"`, awfMaxAICreditsVarName, expr) - } - awfHelpersLog.Printf("Injected maxAiCredits local var reference into AWF config JSON") - } - // Write the config JSON to ${RUNNER_TEMP}/gh-aw/awf-config.json before AWF runs. - // When the generated JSON contains compiler-owned runtime variables such as - // ${GH_AW_MAX_AI_CREDITS} or ${RUNNER_TEMP}, use shellEscapeArgWithVarsPreserved - // which always uses double-quote wrapping: it escapes bare $ signs (e.g. - // "$schema" → "\$schema") while preserving both ${{ }} GitHub Actions expressions - // (e.g. in AllowedDomains) and approved shell variable references so bash expands - // them to runtime-resolved values. When no such variables are injected, - // shellEscapeArg handles escaping normally. - // Also copy it to /tmp/gh-aw/awf-config.json for the unified agent artifact upload. - var printfArg string - preservedVars := make([]string, 0, 2) - if maxAICreditsExportLine != "" { - preservedVars = append(preservedVars, awfMaxAICreditsVarName) - } - if strings.Contains(awfConfigJSON, awfArcDindRootPathExpr) { - preservedVars = append(preservedVars, "RUNNER_TEMP") - } - if len(preservedVars) > 0 { - printfArg = shellEscapeArgWithVarsPreserved(awfConfigJSON, preservedVars...) - } else { - printfArg = shellEscapeArg(awfConfigJSON) - } - // SC2016 ("Expressions don't expand in single quotes") is only triggered when - // printfArg is single-quoted (no runtime variables injected). Double-quoted args - // already escape bare $ signs as \$schema, so shellcheck does not warn there. - var printfLine string - if strings.HasPrefix(printfArg, "'") { - printfLine = "# shellcheck disable=SC2016\nprintf '%%s\\n' %s > %q" - } else { - printfLine = "printf '%%s\\n' %s > %q" - } - configFileSetup = fmt.Sprintf(printfLine, printfArg, awfConfigRuntimePathExpr) - if maxAICreditsExportLine != "" { - configFileSetup = maxAICreditsExportLine + "\n" + configFileSetup - } - if shouldUseWorkflowCallNetworkAllowedInput(config.WorkflowData) { - updateScript, updateErr := buildWorkflowCallNetworkAllowedUpdateScript() - if updateErr != nil { - awfHelpersLog.Printf("Warning: failed to build workflow_call network_allowed updater: %v", updateErr) - } else { - configFileSetup += "\n" + updateScript - } - } - configFileSetup += fmt.Sprintf("\ncp %q %s", awfConfigRuntimePathExpr, constants.AWFConfigFilePath) - // Add --config as the first expandable arg so it appears before --container-workdir. - expandableArgs = fmt.Sprintf("--config %q ", awfConfigRuntimePathExpr) + expandableArgs - awfHelpersLog.Print("Using AWF config file (--config flag)") - } - modelsJSONPathExport := buildModelsJSONPathExportScript(isArcDind) - - // When upload_artifact is configured, add a read-write mount for the staging directory - // so the model can copy files there from inside the container. The parent ${RUNNER_TEMP}/gh-aw - // is mounted :ro above; this child mount overrides access for the staging subdirectory only. - // The staging directory must already exist on the host (created in Generate Safe Outputs Config step). - if config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil { - stagingDir := SafeOutputsUploadArtifactsDir - expandableArgs += fmt.Sprintf(` --mount "%s:%s:rw"`, stagingDir, stagingDir) - awfHelpersLog.Print("Added read-write mount for upload_artifact staging directory") - } - - // 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..ports[''] }} expressions that include single quotes. - // These expressions are resolved by the GitHub Actions runner before shell execution, - // so they must not be shell-escaped. - 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 - if isArcDind { - engineCommand = rewriteArcDindEngineCommand(engineCommand) - } - - // Wrap engine command in shell (command already includes any internal setup like npm PATH) - shellWrappedCommand := WrapCommandInShell(engineCommand) - - // Pre-create the agent stdio log file with restrictive permissions (0600) before - // starting the AWF container. tee would otherwise create it with the default - // umask (0644), leaving secrets (e.g. MCP gateway tokens) world-readable on the - // runner host until the secret-redaction step runs. - preCreateLog := fmt.Sprintf("(umask 177 && touch %s)", shellEscapeArg(config.LogFile)) - - // Capture the epoch-millisecond timestamp at the very start of the Execute Agent CLI - // step on the host, before the AWF container launches. sendJobConclusionSpan reads - // this file to set the dedicated gh-aw..agent span start time, which excludes - // pre-agent overhead such as workspace audit and CLI proxy startup. - writeAgentCLIStartMs := "printf '%s' \"$(date +%s%3N)\" > " + shellEscapeArg(AgentCLIStartMsPath) - - // Build the complete command with proper formatting. - // configFileSetup (if non-empty) writes the AWF config JSON immediately before the - // AWF invocation so the file is present when AWF parses --config. - // - // shellcheck directive rationale: - // - SC1003 is expected because this generated block intentionally contains GitHub - // expression literals (for example ${{ job.services..ports[''] }}) - // that include single quotes and must survive into runtime unchanged. - // - SC2086 is expected because a subset of AWF arguments are intentionally emitted - // as expandable shell fragments (for example ${GH_AW_TOOL_CACHE_MOUNT:+...} and - // ${GH_AW_DOCKER_HOST:+...}). These fragments are produced by trusted - // compiler-owned probes above and are not user-provided free-form shell input. - // - // We keep normal quoting for all user-controlled values via shellEscapeArg/shellJoinArgs - // and scope this suppression to the generated AWF invocation line only. - var command string - if config.PathSetup != "" && configFileSetup != "" { - command = fmt.Sprintf(`set -o pipefail -%s -%s -%s -%s -%s -%s -%s -%s -%s -%s %s %s %s %s \ - -- %s 2>&1 | tee -a %s`, - writeAgentCLIStartMs, - config.PathSetup, - preCreateLog, - configFileSetup, - modelsJSONPathExport, - arcDindDockerHostProbe, - arcDindPrefixProbe, - toolCacheMountProbe, - awfShellcheckDirective, - awfCommand, - expandableArgs, - toolCacheMountRef, - arcDindDockerHostRef, - shellJoinArgs(awfArgs), - shellWrappedCommand, - shellEscapeArg(config.LogFile)) - } else if config.PathSetup != "" { - // Include path setup before AWF command (runs on host before AWF) - command = fmt.Sprintf(`set -o pipefail -%s -%s -%s -%s -%s -%s -%s -%s -%s %s %s %s %s \ - -- %s 2>&1 | tee -a %s`, - writeAgentCLIStartMs, - config.PathSetup, - preCreateLog, - modelsJSONPathExport, - arcDindDockerHostProbe, - arcDindPrefixProbe, - toolCacheMountProbe, - awfShellcheckDirective, - awfCommand, - expandableArgs, - toolCacheMountRef, - arcDindDockerHostRef, - shellJoinArgs(awfArgs), - shellWrappedCommand, - shellEscapeArg(config.LogFile)) - } else if configFileSetup != "" { - command = fmt.Sprintf(`set -o pipefail -%s -%s -%s -%s -%s -%s -%s -%s -%s %s %s %s %s \ - -- %s 2>&1 | tee -a %s`, - writeAgentCLIStartMs, - preCreateLog, - configFileSetup, - modelsJSONPathExport, - arcDindDockerHostProbe, - arcDindPrefixProbe, - toolCacheMountProbe, - awfShellcheckDirective, - awfCommand, - expandableArgs, - toolCacheMountRef, - arcDindDockerHostRef, - shellJoinArgs(awfArgs), - shellWrappedCommand, - shellEscapeArg(config.LogFile)) - } else { - command = fmt.Sprintf(`set -o pipefail -%s -%s -%s -%s -%s -%s -%s -%s %s %s %s %s \ - -- %s 2>&1 | tee -a %s`, - writeAgentCLIStartMs, - preCreateLog, - modelsJSONPathExport, - arcDindDockerHostProbe, - arcDindPrefixProbe, - toolCacheMountProbe, - awfShellcheckDirective, - awfCommand, - expandableArgs, - toolCacheMountRef, - arcDindDockerHostRef, - shellJoinArgs(awfArgs), - shellWrappedCommand, - shellEscapeArg(config.LogFile)) - } - - awfHelpersLog.Print("Successfully built AWF command") - return command -} - -// BuildAWFArgs constructs common AWF arguments from configuration. -// This extracts the shared AWF argument building logic from engine implementations. -// -// 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 -// - --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 -// -// Returns: -// - []string: List of AWF arguments (safe args only; expandable-var args like -// --container-workdir and --mount are handled by BuildAWFCommand) -func BuildAWFArgs(config AWFCommandConfig) []string { - awfHelpersLog.Printf("Building AWF args for engine: %s", config.EngineName) - - firewallConfig := getFirewallConfig(config.WorkflowData) - agentConfig := getAgentConfig(config.WorkflowData) - - var awfArgs []string - - // Add TTY flag if needed (Claude requires this), except for docker-sbx where - // sbx exec --tty can terminate long-running Claude sessions prematurely. - if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) { - awfArgs = append(awfArgs, "--tty") - } - - // docker-sbx: tell AWF to launch the agent inside a Docker sbx microVM instead - // of as a standard Docker Compose service. Guard on the effective AWF version so - // older binaries do not receive an unknown flag. - if isDockerSbxRuntime(config.WorkflowData) && awfSupportsContainerRuntime(firewallConfig) { - awfArgs = append(awfArgs, "--container-runtime", "sbx") - awfHelpersLog.Print("Added --container-runtime sbx for docker-sbx microVM runtime") - } else if isDockerSbxRuntime(config.WorkflowData) { - awfHelpersLog.Printf("Skipping --container-runtime sbx: AWF version %q is older than required minimum %s", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) - } - - // Pass all environment variables to the container, but exclude every variable whose - // step-env value comes from a GitHub Actions secret. AWF's API proxy (--enable-api-proxy) - // handles authentication for these tokens transparently, so the container does not need - // the raw values. Excluding them via --exclude-env prevents a prompt-injected agent from - // exfiltrating tokens through bash tools such as `env` or `printenv`. - // The caller computes ExcludeEnvVarNames from ComputeAWFExcludeEnvVarNames() so that every - // secret-bearing variable is covered — not just a hardcoded subset. - // --exclude-env requires AWF v0.25.3+; skip the flags for workflows that pin an older version. - awfArgs = append(awfArgs, "--env-all") - if awfSupportsExcludeEnv(firewallConfig) { - // Sort for deterministic output in compiled lock files. - sortedExclude := make([]string, len(config.ExcludeEnvVarNames)) - copy(sortedExclude, config.ExcludeEnvVarNames) - sort.Strings(sortedExclude) - for _, excludedVar := range sortedExclude { - awfArgs = append(awfArgs, "--exclude-env", excludedVar) - } - } else { - awfHelpersLog.Printf("Skipping --exclude-env: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFExcludeEnvMinVersion) - } - - // Note: --container-workdir "${GITHUB_WORKSPACE}" and --mount "${RUNNER_TEMP}/gh-aw:..." - // are intentionally NOT added here. They contain shell variable references that require - // double-quote expansion. These args are appended raw in BuildAWFCommand to ensure - // ${GITHUB_WORKSPACE} and ${RUNNER_TEMP} are expanded by the runner's shell. - - // Add custom mounts from agent config if specified - if agentConfig != nil && len(agentConfig.Mounts) > 0 { - // Sort mounts for consistent output - sortedMounts := make([]string, len(agentConfig.Mounts)) - copy(sortedMounts, agentConfig.Mounts) - sort.Strings(sortedMounts) - - for _, mount := range sortedMounts { - awfArgs = append(awfArgs, "--mount", mount) - } - awfHelpersLog.Printf("Added %d custom mounts from agent config", len(sortedMounts)) - } - - // Set log level - awfLogLevel := string(constants.AWFDefaultLogLevel) - if firewallConfig != nil && firewallConfig.LogLevel != "" { - awfLogLevel = firewallConfig.LogLevel - } - awfArgs = append(awfArgs, "--log-level", awfLogLevel) - if isFeatureEnabled(constants.AwfDiagnosticLogsFeatureFlag, config.WorkflowData) { - awfArgs = append(awfArgs, "--diagnostic-logs") - awfHelpersLog.Print("Added --diagnostic-logs because awf-diagnostic-logs feature flag is enabled") - } - - // Legacy security mode: emit --legacy-security, --enable-host-access, and --allow-host-ports - isLegacy := agentConfig != nil && agentConfig.LegacySecurity - if isLegacy { - 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 legacy security mode") - - if awfSupportsAllowHostPorts(firewallConfig) { - mcpGatewayPort := int(DefaultMCPGatewayPort) - if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && - config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 { - mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port - } - hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort) - awfArgs = append(awfArgs, "--allow-host-ports", hostPorts) - awfHelpersLog.Printf("Added --allow-host-ports %s for legacy security mode", hostPorts) - } - } else { - awfHelpersLog.Print("Strict security: skipping host-access flags (default)") - } - - // Skip pulling images since they are pre-downloaded - awfArgs = append(awfArgs, "--skip-pull") - awfHelpersLog.Print("Using --skip-pull since images are pre-downloaded") - - // Enable CLI proxy sidecar when GitHub mode is gh-proxy. - // Start the difc-proxy on the host and tell AWF where to connect - // (firewall v0.25.17+). - if isGitHubCLIModeEnabled(config.WorkflowData) { - if awfSupportsCliProxy(firewallConfig) { - difcProxyHost := "host.docker.internal:18443" - if isAWFNetworkIsolationEnabled(config.WorkflowData) { - difcProxyHost = "awmg-cli-proxy:18443" - } - awfArgs = append(awfArgs, "--difc-proxy-host", difcProxyHost) - awfArgs = append(awfArgs, "--difc-proxy-ca-cert", constants.TmpDIFCProxyTLSCACert) - awfHelpersLog.Print("Added --difc-proxy-host and --difc-proxy-ca-cert for CLI proxy sidecar") - } else { - awfHelpersLog.Printf("Skipping CLI proxy flags: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFCliProxyMinVersion) - } - } - - // Pass base path if URL contains a path component - // This is required for endpoints with path prefixes (e.g., Databricks /serving-endpoints, - // Azure OpenAI /openai/deployments/, corporate LLM routers with path-based routing) - // Base paths remain as CLI flags — they are not yet represented in the config file schema. - openaiBasePath := extractAPIBasePath(config.WorkflowData, "OPENAI_BASE_URL") - if openaiBasePath != "" { - awfArgs = append(awfArgs, "--openai-api-base-path", openaiBasePath) - awfHelpersLog.Printf("Added --openai-api-base-path=%s", openaiBasePath) - } - - anthropicBasePath := extractAPIBasePath(config.WorkflowData, "ANTHROPIC_BASE_URL") - if anthropicBasePath != "" { - awfArgs = append(awfArgs, "--anthropic-api-base-path", anthropicBasePath) - awfHelpersLog.Printf("Added --anthropic-api-base-path=%s", anthropicBasePath) - } - - geminiBasePath := extractAPIBasePath(config.WorkflowData, "GEMINI_API_BASE_URL") - if geminiBasePath != "" { - awfArgs = append(awfArgs, "--gemini-api-base-path", geminiBasePath) - awfHelpersLog.Printf("Added --gemini-api-base-path=%s", geminiBasePath) - } - - // Add SSL Bump support for HTTPS content inspection (v0.9.0+) - sslBumpArgs := getSSLBumpArgs(firewallConfig) - awfArgs = append(awfArgs, sslBumpArgs...) - - // Add custom args if specified in firewall config - if firewallConfig != nil && len(firewallConfig.Args) > 0 { - awfArgs = append(awfArgs, firewallConfig.Args...) - } - - // Add custom args from agent config if specified - if agentConfig != nil && len(agentConfig.Args) > 0 { - awfArgs = append(awfArgs, agentConfig.Args...) - awfHelpersLog.Printf("Added %d custom args from agent config", len(agentConfig.Args)) - } - - // Pass memory limit to AWF container if specified in agent config - if agentConfig != nil && agentConfig.Memory != "" { - awfArgs = append(awfArgs, "--memory-limit", agentConfig.Memory) - awfHelpersLog.Printf("Set AWF memory limit to %s", agentConfig.Memory) - } - - awfHelpersLog.Printf("Built %d AWF arguments", len(awfArgs)) - return awfArgs -} - -// GetAWFCommandPrefix determines the AWF command to use (custom or standard). -// This extracts the common pattern for determining AWF command from agent config. -// -// Parameters: -// - workflowData: The workflow data containing agent configuration -// -// Returns: -// - string: The AWF command to use (e.g., "sudo -E awf", "awf", or custom command) -func GetAWFCommandPrefix(workflowData *WorkflowData) string { - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil && agentConfig.Command != "" { - awfHelpersLog.Printf("Using custom AWF command: %s", agentConfig.Command) - return agentConfig.Command - } - - // 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) - } - - // Default strict security: AWF runs rootless (no sudo) - awfHelpersLog.Print("Using standard AWF command (strict security, no sudo)") - return string(constants.AWFDefaultCommand) -} - -// buildAWFImageTagWithDigests returns an image tag value for AWF's --image-tag flag. -// When known firewall container digests are available, it appends AWF's digest -// metadata format: -// -// ,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:... -// -// For arc-dind topology, build-tools is also included: -// -// ,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...,build-tools=sha256:... -// -// This keeps AWF sidecar configuration aligned with digest-pinned pre-download images. -func buildAWFImageTagWithDigests(imageTag string, workflowData *WorkflowData) string { - if imageTag == "" { - return imageTag - } - - type digestSpec struct { - name string - image string - } - specs := []digestSpec{ - {name: "squid", image: constants.DefaultFirewallRegistry + "/squid:" + imageTag}, - {name: "agent", image: constants.DefaultFirewallRegistry + "/agent:" + imageTag}, - {name: "agent-act", image: constants.DefaultFirewallRegistry + "/agent-act:" + imageTag}, - {name: "api-proxy", image: constants.DefaultFirewallRegistry + "/api-proxy:" + imageTag}, - {name: "cli-proxy", image: constants.DefaultFirewallRegistry + "/cli-proxy:" + imageTag}, - } - if isArcDindTopology(workflowData) { - specs = append(specs, digestSpec{name: "build-tools", image: constants.DefaultFirewallRegistry + "/build-tools:" + imageTag}) - } - - parts := []string{imageTag} - for _, spec := range specs { - digest := lookupContainerDigest(spec.image, workflowData) - if digest == "" { - continue - } - parts = append(parts, spec.name+"="+digest) - } - - if len(parts) == 1 { - return imageTag - } - return strings.Join(parts, ",") -} - -// lookupContainerDigest resolves a container image digest from cache first, then -// falls back to embedded container pins. -func lookupContainerDigest(image string, workflowData *WorkflowData) string { - var cache *ActionCache - if workflowData != nil { - cache = workflowData.ActionCache - } - if pin, ok := lookupContainerPin(image, cache); ok && pin.Digest != "" { - return pin.Digest - } - return "" -} - -// WrapCommandInShell wraps an engine command in a shell invocation for AWF execution. -// This is needed because AWF requires commands to be wrapped in shell for proper execution. -// -// set +o histexpand disables bash history expansion so that agent-authored strings -// containing '!' characters (e.g. "!**") cannot be silently misinterpreted or dropped. -// History expansion is meaningless for non-interactive execution and has no other effect. -// -// Parameters: -// - command: The engine command to wrap (may include PATH setup and other initialization) -// -// Returns: -// - string: Shell-wrapped command suitable for AWF execution -func WrapCommandInShell(command string) string { - awfHelpersLog.Print("Wrapping command in shell for AWF execution") - - // Escape single quotes in the command by replacing ' with '\'' - escapedCommand := strings.ReplaceAll(command, "'", "'\\''") - - // Wrap in shell invocation. - // set +o histexpand is first to prevent bash from expanding !-patterns in any - // double-quoted strings that appear in the engine command or its arguments. - return fmt.Sprintf("/bin/bash -c 'set +o histexpand; %s'", escapedCommand) -} - -// ComputeAWFExcludeEnvVarNames returns the list of environment variable names that must be -// excluded from the agent container's visible environment via AWF's --exclude-env flag. -// -// Env var names are included when their step-env values contain a ${{ secrets.* }} reference -// OR a ${{ needs.JOB.outputs.OUTPUT }} job-output expression (which commonly carries -// ephemeral tokens such as GitHub App installation tokens). Non-secret static vars -// (e.g. GH_DEBUG: "1" in mcp-scripts) are never excluded. -// -// Parameters: -// - workflowData: the workflow being compiled -// - coreSecretVarNames: engine-specific fixed secret env var names (e.g. ["COPILOT_GITHUB_TOKEN"]) -// -// The function augments coreSecretVarNames with: -// - MCP_GATEWAY_API_KEY when MCP servers are present -// - GITHUB_MCP_SERVER_TOKEN when the GitHub tool is present -// - HTTP MCP header secret var names (values always contain ${{ secrets.* }}) -// - mcp-scripts env var names whose values contain ${{ secrets.* }} or a job-output expression -// - engine.env var names whose values contain ${{ secrets.* }} or a job-output expression -// - agent.env var names whose values contain ${{ secrets.* }} or a job-output expression -// - names listed in the frontmatter excluded-env field (unconditionally) -func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames []string) []string { - seen := make(map[string]struct { - }) - var names []string - - addUnique := func(name string) { - if !setutil.Contains(seen, name) { - seen[name] = struct { - }{} - names = append(names, name) - } - } - - // Core secret vars for this engine (always contain secret references). - for _, name := range coreSecretVarNames { - addUnique(name) - } - - // MCP gateway API key is always a secret when MCP servers are present. - if HasMCPServers(workflowData) { - addUnique("MCP_GATEWAY_API_KEY") - } - - // GitHub MCP server token is always a secret when the GitHub tool is present. - if hasGitHubTool(workflowData.ParsedTools) { - addUnique("GITHUB_MCP_SERVER_TOKEN") - } - - // HTTP MCP header secrets: values are always ${{ secrets.* }} references. - for varName := range collectHTTPMCPHeaderSecrets(workflowData.Tools) { - addUnique(varName) - } - - // mcp-scripts env vars: only add those whose configured values contain a secret reference - // or a job-output expression (e.g. ${{ needs.fetch_token.outputs.token }}). - // (Non-secret vars like GH_DEBUG: "1" must NOT be excluded.) - if workflowData.MCPScripts != nil { - for _, toolConfig := range workflowData.MCPScripts.Tools { - for envName, envValue := range toolConfig.Env { - if strings.Contains(envValue, "${{ secrets.") || ContainsJobOutputExpr(envValue) { - addUnique(envName) - } - } - } - } - - // engine.env vars that contain a secret reference or a job-output expression. - if workflowData.EngineConfig != nil { - for varName, varValue := range workflowData.EngineConfig.Env { - if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { - addUnique(varName) - } - } - } - - // agent.env vars that contain a secret reference or a job-output expression. - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil { - for varName, varValue := range agentConfig.Env { - if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { - addUnique(varName) - } - } - } - - // GH_TOKEN when GitHub mode is gh-proxy: the token is passed in the AWF step env for the - // host difc-proxy but must be excluded from the agent container. - if isGitHubCLIModeEnabled(workflowData) { - addUnique("GH_TOKEN") - } - - // Actions OIDC request credentials must never be visible to the sandboxed AWF agent. - // The runner-owned gateway forwards them only for HTTP MCP github-oidc authentication. - addUnique("ACTIONS_ID_TOKEN_REQUEST_URL") - addUnique("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - if enclavesEnabled(workflowData) { - addUnique(enclaveMCPCapabilityEnv) - addUnique(enclaveMCPGatewayContainerEnv) - addUnique(enclaveMCPGatewayEndpointEnv) - addUnique(enclaveMCPGatewayIdentityEnv) - addUnique(enclaveMCPReadinessTimeoutEnv) - } - - // Explicitly excluded env vars from the frontmatter excluded-env field. - // These are always excluded regardless of their value content. - for _, name := range workflowData.ExcludedEnv { - addUnique(name) - } - - awfHelpersLog.Printf("Computed %d AWF env vars to exclude", len(names)) - return names -} - -// addCliProxyGHTokenToEnv adds GH_TOKEN to the AWF step environment when GitHub -// mode is gh-proxy. The token is NOT used by AWF or its cli-proxy -// sidecar directly — the host difc-proxy (started by start_cli_proxy.sh) already -// has it. However, --env-all passes all step env vars into the agent container, -// so we explicitly set GH_TOKEN here to ensure --exclude-env GH_TOKEN can -// reliably strip it regardless of how the token enters the environment. -// The token is excluded from the agent container via --exclude-env GH_TOKEN, so only -// inject it when the effective AWF version supports both cli-proxy flags and -// --exclude-env. -// -// #nosec G101 -- This is NOT a hardcoded credential. It is a GitHub Actions expression -// template that is resolved at runtime by the GitHub Actions runner. -func addCliProxyGHTokenToEnv(env map[string]string, workflowData *WorkflowData) { - firewallConfig := getFirewallConfig(workflowData) - if isGitHubCLIModeEnabled(workflowData) && - isFirewallEnabled(workflowData) && - awfSupportsCliProxy(firewallConfig) && - awfSupportsExcludeEnv(firewallConfig) { - env["GH_TOKEN"] = "${{ secrets.GH_AW_GITHUB_TOKEN || github.token }}" - awfHelpersLog.Print("Added GH_TOKEN to env for CLI proxy (excluded from agent container)") - } -} - -// awfSupportsExcludeEnv returns true when the effective AWF version supports --exclude-env -// (introduced in AWF v0.25.3). -func awfSupportsExcludeEnv(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFExcludeEnvMinVersion) -} - -// awfVersionAtLeast returns true when the effective AWF version is at or above minVersion. -// -// If firewallConfig has no version set, DefaultFirewallVersion is used. "latest" always -// returns true. Non-semver strings (e.g. branch names) return false (conservative). -func awfVersionAtLeast(firewallConfig *FirewallConfig, minVersion constants.Version) bool { - var versionStr string - if firewallConfig != nil && firewallConfig.Version != "" { - versionStr = firewallConfig.Version - } - return versionAtLeast(versionStr, string(constants.DefaultFirewallVersion), string(minVersion)) -} - -// awfSupportsCliProxy returns true when the effective AWF version supports --difc-proxy-host -// and --difc-proxy-ca-cert (introduced in AWF v0.26.0). -func awfSupportsCliProxy(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFCliProxyMinVersion) -} - -// awfSupportsAllowHostPorts returns true when the effective AWF version supports -// --allow-host-ports. -func awfSupportsAllowHostPorts(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFAllowHostPortsMinVersion) -} - -// awfSupportsDockerHostPathPrefix returns true when the effective AWF version supports -// --docker-host-path-prefix. -func awfSupportsDockerHostPathPrefix(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFDockerHostPathPrefixMinVersion) -} - -// awfSupportsTokenSteering returns true when the effective AWF version supports -// apiProxy.enableTokenSteering. -func awfSupportsTokenSteering(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFTokenSteeringMinVersion) -} - -// awfSupportsChrootConfig returns true when the effective AWF version supports -// chroot.binariesSourcePath and chroot.identity.* in the config file (AWF v0.27.1+). -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) -} - -// 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) -} - -// awfSupportsDefaultAiCreditsPricing returns true when apiProxy.defaultAiCreditsPricing -// survives AWF config resolution and reaches the api-proxy container. -func awfSupportsDefaultAiCreditsPricing(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFDefaultAiCreditsPricingMinVersion) -} - -// awfSupportsAPIProxyProviders returns true when the effective AWF version supports -// apiProxy.providers in awf-config.json. -func awfSupportsAPIProxyProviders(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFAPIProxyProvidersMinVersion) -} - -// awfSupportsBoundedQueries returns true when the effective AWF version supports -// the boundedQueries section in awf-config.json. -func awfSupportsBoundedQueries(firewallConfig *FirewallConfig) bool { - return awfVersionAtLeast(firewallConfig, constants.AWFBoundedQueriesMinVersion) -} - -// 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://... -// -// Using the repository JavaScript helper avoids a runtime Python dependency and keeps the -// patch logic aligned with the rest of the actions/setup/js helpers. -// The config path under ${RUNNER_TEMP}/gh-aw is updated in place. -func buildArcDindChrootConfigPatchBody() string { - return fmt.Sprintf( - ` GH_AW_CHROOT_BINARIES_SOURCE_PATH="%s" GH_AW_CHROOT_IDENTITY_HOME="%s" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"`, - awfArcDindChrootBinariesSourcePath, - awfArcDindChrootIdentityHome, - ) -} - -// buildArcDindChrootConfigPatchBodyBash returns bash commands (using jq) that patch the AWF -// config file with chroot.binariesSourcePath and chroot.identity.*. This is the bash -// equivalent of buildArcDindChrootConfigPatchBody, used for detection runs where Python -// must not be injected. -// The config path under ${RUNNER_TEMP}/gh-aw is updated in place. -func buildArcDindChrootConfigPatchBodyBash() string { - return fmt.Sprintf( - ` _GH_AW_CHROOT_JSON=$(jq -c --arg src "%s" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "%s" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } - printf '%%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - printf '%%s\n' "$_GH_AW_CHROOT_JSON" > "%s/awf-config.json"`, - awfArcDindChrootBinariesSourcePath, - awfArcDindChrootIdentityHome, - awfArcDindChrootBinariesSourcePath, - ) -} diff --git a/pkg/workflow/awf_helpers_test.go b/pkg/workflow/awf_helpers_test.go index 492cbaf52f6..a0bba58d656 100644 --- a/pkg/workflow/awf_helpers_test.go +++ b/pkg/workflow/awf_helpers_test.go @@ -3,14 +3,10 @@ package workflow import ( - "fmt" - "os/exec" - "strconv" "strings" "testing" "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/workflow/compilerenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -136,6 +132,7 @@ func TestExtractAPITargetHost(t *testing.T) { // when OPENAI_BASE_URL or ANTHROPIC_BASE_URL are configured in engine.env. // With config file support (default AWF version), API targets move to the JSON config // rather than being emitted as --*-api-target CLI flags. + func TestAWFCustomAPITargetFlags(t *testing.T) { t.Run("includes openai target in config JSON when OPENAI_BASE_URL is configured", func(t *testing.T) { workflowData := &WorkflowData{ @@ -274,48 +271,6 @@ func TestAWFCustomAPITargetFlags(t *testing.T) { }) } -func TestApplyDefaultMaxAICreditsEnvToMap(t *testing.T) { - t.Run("sets default agent expression when max-ai-credits is unset", func(t *testing.T) { - env := map[string]string{} - applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot"}, - }) - assert.Equal(t, compilerenv.BuildDefaultMaxAICreditsExpression(strconv.FormatInt(constants.DefaultMaxAICredits, 10)), env[awfMaxAICreditsVarName]) - }) - - t.Run("sets default detection expression for detection runs", func(t *testing.T) { - env := map[string]string{} - applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ - IsDetectionRun: true, - EngineConfig: &EngineConfig{ID: "copilot"}, - }) - assert.Equal(t, compilerenv.BuildDefaultDetectionMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)), env[awfMaxAICreditsVarName]) - }) - - t.Run("sets default evals expression for evals runs", func(t *testing.T) { - env := map[string]string{} - applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ - IsEvalsRun: true, - EngineConfig: &EngineConfig{ID: "copilot"}, - }) - assert.Equal(t, compilerenv.BuildDefaultEvalsMaxAICreditsExpression(strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)), env[awfMaxAICreditsVarName]) - }) - - t.Run("does not set expression when max-ai-credits is configured", func(t *testing.T) { - env := map[string]string{} - applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{ - EngineConfig: &EngineConfig{ - ID: "copilot", - MaxAICredits: 777, - }, - }) - _, exists := env[awfMaxAICreditsVarName] - assert.False(t, exists) - }) -} - -// TestExtractAPITargetAuthHeader tests the extractAPITargetAuthHeader function that reads -// the custom auth header name from sandbox.agent.targets..authHeader in frontmatter. func TestExtractAPITargetAuthHeader(t *testing.T) { makeWorkflowData := func(provider, authHeader string) *WorkflowData { return &WorkflowData{ @@ -371,6 +326,7 @@ func TestExtractAPITargetAuthHeader(t *testing.T) { // TestExtractAPIBasePath tests the extractAPIBasePath function that extracts // path components from custom API base URLs in engine.env + func TestExtractAPIBasePath(t *testing.T) { tests := []struct { name string @@ -429,6 +385,7 @@ func TestExtractAPIBasePath(t *testing.T) { // --anthropic-api-base-path when the configured URLs contain a path component. // Note: API targets (hosts) move to the JSON config file, while base paths remain // as CLI flags — they are not yet represented in the AWF config file schema. + func TestAWFBasePathFlags(t *testing.T) { t.Run("includes openai-api-base-path when OPENAI_BASE_URL has path component", func(t *testing.T) { workflowData := &WorkflowData{ @@ -531,321 +488,7 @@ func TestAWFBasePathFlags(t *testing.T) { // TestBuildAWFArgsAuditDir tests that audit-dir and proxy-logs-dir are emitted in config, // not CLI flags, for both standard and ARC/DinD workflows. -func TestBuildAWFArgsAuditDir(t *testing.T) { - t.Run("non-arc-dind omits audit-dir and proxy-logs-dir from CLI flags", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - }, - }, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - // Non-ARC/DinD: these should be in config, not CLI flags - assert.NotContains(t, argsStr, "--audit-dir", "audit-dir should be in config for non-arc-dind") - assert.NotContains(t, argsStr, "--proxy-logs-dir", "proxy-logs-dir should be in config for non-arc-dind") - }) - - t.Run("arc-dind also omits audit-dir and proxy-logs-dir from CLI flags", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - }, - }, - RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--audit-dir", "arc-dind audit-dir should be emitted via config JSON") - assert.NotContains(t, argsStr, "--proxy-logs-dir", "arc-dind proxy-logs-dir should be emitted via config JSON") - }) -} - -// TestBuildAWFArgsAllowHostPorts tests that BuildAWFArgs includes --allow-host-ports -// with port 80, 443, and the MCP gateway port so the AWF agent container can reach -// the gateway through the firewall's iptables rules. -func TestBuildAWFArgsAllowHostPorts(t *testing.T) { - t.Run("includes default MCP gateway port 8080", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{LegacySecurity: true}, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") - assert.Contains(t, argsStr, "80,443,8080", "Should allow default gateway port 8080 alongside 80 and 443") - }) - - t.Run("uses custom MCP gateway port from sandbox config", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{LegacySecurity: true}, - MCP: &MCPGatewayRuntimeConfig{Port: 9090}, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--allow-host-ports", "Should include --allow-host-ports flag") - assert.Contains(t, argsStr, "80,443,9090", "Should use custom gateway port from sandbox config") - assert.NotContains(t, argsStr, "8080", "Should not include default port when custom port is set") - }) - - t.Run("handles nil SandboxConfig gracefully — strict mode skips host-access", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--allow-host-ports", "Strict mode (default) should not emit --allow-host-ports") - assert.NotContains(t, argsStr, "--enable-host-access", "Strict mode (default) should not emit --enable-host-access") - }) - - t.Run("skips --allow-host-ports when AWF version is too old", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - Version: "v0.25.23", - }, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports for AWF versions below minimum support") - }) - - t.Run("skips host-access flags when network isolation is enabled", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - Type: SandboxTypeAWF, - NetworkIsolation: true, - }, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--enable-host-access", "Should skip --enable-host-access in network isolation mode") - assert.NotContains(t, argsStr, "--allow-host-ports", "Should skip --allow-host-ports in network isolation mode") - }) -} - -// TestBuildAWFArgsDiagnosticLogs tests that BuildAWFArgs includes --diagnostic-logs -// only when features.awf-diagnostic-logs is enabled. -func TestBuildAWFArgsDiagnosticLogs(t *testing.T) { - baseWorkflow := func(features map[string]any) *WorkflowData { - return &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - Features: features, - } - } - - t.Run("does not include --diagnostic-logs when feature flag is absent", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: baseWorkflow(nil), - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--diagnostic-logs", "Should not include --diagnostic-logs when feature flag is absent") - }) - - t.Run("includes --diagnostic-logs when awf-diagnostic-logs is enabled", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: baseWorkflow(map[string]any{ - string(constants.AwfDiagnosticLogsFeatureFlag): true, - }), - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--diagnostic-logs", "Should include --diagnostic-logs when feature flag is enabled") - }) -} - -// TestBuildAWFArgsMemoryLimit tests that BuildAWFArgs passes --memory-limit -// when sandbox.agent.memory is configured in the workflow frontmatter -func TestBuildAWFArgsMemoryLimit(t *testing.T) { - t.Run("includes --memory-limit flag when memory is configured", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - }, - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - Memory: "6g", - }, - }, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--memory-limit", "Should include --memory-limit flag") - assert.Contains(t, argsStr, "6g", "Should include the memory value") - }) - - t.Run("does not include --memory-limit flag when memory is not configured", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - }, - }, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--memory-limit", "Should not include --memory-limit when memory is not configured") - }) - - t.Run("includes correct memory value when multiple sizes configured", func(t *testing.T) { - for _, memory := range []string{"512m", "4g", "8g"} { - t.Run(memory, func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - Memory: memory, - }, - }, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--memory-limit", "Should include --memory-limit flag") - assert.Contains(t, argsStr, memory, "Should include the correct memory value") - }) - } - }) -} -// TestEngineExecutionWithCustomAPITarget tests that engine execution steps include -// custom API targets when configured in engine.env. -// With config file support (default AWF version), API targets are in the JSON config. func TestEngineExecutionWithCustomAPITarget(t *testing.T) { t.Run("Codex engine includes openai target in config JSON when OPENAI_BASE_URL is configured", func(t *testing.T) { workflowData := &WorkflowData{ @@ -910,6 +553,7 @@ func TestEngineExecutionWithCustomAPITarget(t *testing.T) { // TestGetCopilotAPITarget tests the GetCopilotAPITarget helper that resolves the effective // Copilot API target from engine.api-target or supported Copilot base URL env vars. + func TestGetCopilotAPITarget(t *testing.T) { tests := []struct { name string @@ -1253,6 +897,7 @@ func TestGetCopilotAllowlistTargets(t *testing.T) { // TestCopilotEngineIncludesCopilotAPITargetFromEnvVar tests that the Copilot engine execution // step includes the copilot API target in the JSON config when GITHUB_COPILOT_BASE_URL is // configured in engine.env. + func TestCopilotEngineIncludesCopilotAPITargetFromEnvVar(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", @@ -1283,928 +928,36 @@ func TestCopilotEngineIncludesCopilotAPITargetFromEnvVar(t *testing.T) { } // TestAWFSupportsExcludeEnv verifies that --exclude-env is only enabled for AWF v0.25.3+. -func TestAWFSupportsExcludeEnv(t *testing.T) { + +func TestGetGeminiAPITarget(t *testing.T) { tests := []struct { - name string - firewallConfig *FirewallConfig - want bool + name string + workflowData *WorkflowData + engineName string + expected string }{ { - name: "nil firewall config (default version) supports --exclude-env", - firewallConfig: nil, - want: true, - }, - { - name: "empty version (default) supports --exclude-env", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "v0.25.3 supports --exclude-env", - firewallConfig: &FirewallConfig{Version: "v0.25.3"}, - want: true, + name: "returns default target for gemini engine with no custom URL", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "gemini", + }, + }, + engineName: "gemini", + expected: "generativelanguage.googleapis.com", }, { - name: "v0.26.0 supports --exclude-env", - firewallConfig: &FirewallConfig{Version: "v0.26.0"}, - want: true, - }, - { - name: "v0.27.0 supports --exclude-env", - firewallConfig: &FirewallConfig{Version: "v0.27.0"}, - want: true, - }, - { - name: "latest supports --exclude-env", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.25.0 does not support --exclude-env", - firewallConfig: &FirewallConfig{Version: "v0.25.0"}, - want: false, - }, - { - name: "v0.1.0 does not support --exclude-env", - firewallConfig: &FirewallConfig{Version: "v0.1.0"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsExcludeEnv(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsExcludeEnv result") - }) - } -} - -// TestComputeAWFExcludeEnvVarNames verifies that engine.env vars whose values contain -// ${{ secrets.* }} are automatically included in the --exclude-env list, and that -// non-secret engine.env vars and plain-value core secrets are handled correctly. -func TestComputeAWFExcludeEnvVarNames(t *testing.T) { - tests := []struct { - name string - workflowData *WorkflowData - coreSecretVarNames []string - want []string - notWant []string - }{ - { - name: "engine.env secret var is auto-excluded", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "GOOGLE_API_KEY": "${{ secrets.SOME_KEY }}", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"GOOGLE_API_KEY"}, - }, - { - name: "engine.env non-secret var is not excluded", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "DEBUG": "true", - "LOG_LEVEL": "info", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{}, - notWant: []string{"DEBUG", "LOG_LEVEL"}, - }, - { - name: "engine.env mixes secret and non-secret vars: only secrets excluded", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "GOOGLE_API_KEY": "${{ secrets.SOME_KEY }}", - "LOG_LEVEL": "debug", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"GOOGLE_API_KEY"}, - notWant: []string{"LOG_LEVEL"}, - }, - { - name: "engine.env secret combined with core secret vars", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "CUSTOM_API_KEY": "${{ secrets.CUSTOM_KEY }}", - }, - }, - }, - coreSecretVarNames: []string{"GEMINI_API_KEY"}, - want: []string{"GEMINI_API_KEY", "CUSTOM_API_KEY"}, - }, - { - name: "engine.env secret embedded in a larger string is excluded", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "AUTH_HEADER": "Bearer ${{ secrets.TOKEN }}", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"AUTH_HEADER"}, - }, - { - name: "nil engine config produces no exclusions beyond core secrets", - workflowData: &WorkflowData{ - EngineConfig: nil, - }, - coreSecretVarNames: []string{"COPILOT_GITHUB_TOKEN"}, - want: []string{"COPILOT_GITHUB_TOKEN"}, - }, - // --- job-output expression tests --- - { - name: "mcp-scripts env var with job-output value is excluded", - workflowData: &WorkflowData{ - MCPScripts: &MCPScriptsConfig{ - Tools: map[string]*MCPScriptToolConfig{ - "example": { - Env: map[string]string{ - "GH_TOKEN": "${{ needs.fetch_token.outputs.token }}", - }, - }, - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"GH_TOKEN"}, - }, - { - name: "mcp-scripts env var with static value is not excluded", - workflowData: &WorkflowData{ - MCPScripts: &MCPScriptsConfig{ - Tools: map[string]*MCPScriptToolConfig{ - "example": { - Env: map[string]string{ - "GH_DEBUG": "1", - }, - }, - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{}, - notWant: []string{"GH_DEBUG"}, - }, - { - name: "engine.env var with job-output value is excluded", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "GITHUB_TOKEN": "${{ needs.token_job.outputs.github_token }}", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"GITHUB_TOKEN"}, - }, - { - name: "engine.env non-credential job-output var is excluded (consistent with secret behavior)", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "REPO_URL": "${{ needs.setup.outputs.repo_url }}", - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"REPO_URL"}, - }, - { - name: "mcp-scripts env var with job-output value mixed with secret: both excluded", - workflowData: &WorkflowData{ - MCPScripts: &MCPScriptsConfig{ - Tools: map[string]*MCPScriptToolConfig{ - "tool1": { - Env: map[string]string{ - "GH_TOKEN": "${{ needs.fetch_token.outputs.token }}", - "API_KEY": "${{ secrets.API_KEY }}", - "STATIC_HOST": "https://api.example.com", - }, - }, - }, - }, - }, - coreSecretVarNames: []string{}, - want: []string{"GH_TOKEN", "API_KEY"}, - notWant: []string{"STATIC_HOST"}, - }, - // --- excluded-env frontmatter field tests --- - { - name: "excluded-env frontmatter field adds names unconditionally", - workflowData: &WorkflowData{ - ExcludedEnv: []string{"MY_CUSTOM_TOKEN", "ANOTHER_SECRET"}, - }, - coreSecretVarNames: []string{}, - want: []string{"MY_CUSTOM_TOKEN", "ANOTHER_SECRET"}, - }, - { - name: "excluded-env combined with core secrets: all excluded", - workflowData: &WorkflowData{ - ExcludedEnv: []string{"CUSTOM_PAT"}, - }, - coreSecretVarNames: []string{"COPILOT_GITHUB_TOKEN"}, - want: []string{"COPILOT_GITHUB_TOKEN", "CUSTOM_PAT"}, - }, - { - name: "excluded-env deduplicates with auto-detected secrets", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - Env: map[string]string{ - "MY_TOKEN": "${{ secrets.MY_TOKEN }}", - }, - }, - ExcludedEnv: []string{"MY_TOKEN"}, - }, - coreSecretVarNames: []string{}, - want: []string{"MY_TOKEN"}, - }, - { - name: "always excludes actions oidc env vars from awf agent", - workflowData: &WorkflowData{}, - coreSecretVarNames: []string{}, - want: []string{ - "ACTIONS_ID_TOKEN_REQUEST_URL", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN", - }, - }, - { - name: "empty excluded-env has no effect", - workflowData: &WorkflowData{ - ExcludedEnv: []string{}, - }, - coreSecretVarNames: []string{}, - want: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ComputeAWFExcludeEnvVarNames(tt.workflowData, tt.coreSecretVarNames) - for _, name := range tt.want { - assert.Contains(t, got, name, "expected %q in exclude list", name) - } - for _, name := range tt.notWant { - assert.NotContains(t, got, name, "expected %q to be absent from exclude list", name) - } - }) - } -} - -// TestBuildAWFArgsCliProxy tests that BuildAWFArgs correctly injects --difc-proxy-host -// and --difc-proxy-ca-cert based on the cli-proxy feature flag. -func TestBuildAWFArgsCliProxy(t *testing.T) { - baseWorkflow := func(features map[string]any, tools map[string]any) *WorkflowData { - return &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - Features: features, - Tools: tools, - } - } - - t.Run("does not include cli-proxy flags when feature flag is absent", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: baseWorkflow(nil, nil), - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host when feature flag is absent") - assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert when feature flag is absent") - assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") - assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") - }) - - t.Run("includes --difc-proxy-host and --difc-proxy-ca-cert when cli-proxy is enabled", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, - }, - Features: map[string]any{"cli-proxy": true}, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled") - assert.Contains(t, argsStr, "host.docker.internal:18443", "Should use host.docker.internal:18443 as proxy host") - assert.Contains(t, argsStr, "--difc-proxy-ca-cert", "Should include --difc-proxy-ca-cert") - assert.Contains(t, argsStr, "/tmp/gh-aw/difc-proxy-tls/ca.crt", "Should use the correct CA cert path") - assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") - assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") - }) - - t.Run("uses internal cli proxy host when network isolation is enabled", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, - }, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - Type: SandboxTypeAWF, - NetworkIsolation: true, - }, - }, - Features: map[string]any{"cli-proxy": true}, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host when cli-proxy is enabled") - assert.Contains(t, argsStr, "awmg-cli-proxy:18443", "Should use internal awf-net CLI proxy address in isolation mode") - assert.NotContains(t, argsStr, "host.docker.internal:18443", "Should not use host.docker.internal in isolation mode") - }) - - t.Run("does not include cli-proxy flags for copilot by default", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, - }, - Features: map[string]any{}, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host for copilot by default") - assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert for copilot by default") - }) - - t.Run("does not include deprecated flags even with guard policy configured", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true, Version: "v0.26.0"}, - }, - Features: map[string]any{"cli-proxy": true}, - Tools: map[string]any{ - "github": map[string]any{ - "min-integrity": "approved", - }, - }, - }, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.Contains(t, argsStr, "--difc-proxy-host", "Should include --difc-proxy-host") - assert.Contains(t, argsStr, "--difc-proxy-ca-cert", "Should include --difc-proxy-ca-cert") - assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") - assert.NotContains(t, argsStr, "--cli-proxy-policy", "Should not include deprecated --cli-proxy-policy") - }) - - t.Run("skips all cli-proxy flags when AWF version is too old", func(t *testing.T) { - // Simulate a workflow that pins an AWF version older than AWFCliProxyMinVersion - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "copilot", - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - Version: "v0.25.16", // older than AWFCliProxyMinVersion v0.25.17 - }, - }, - Features: map[string]any{ - "cli-proxy": true, - }, - Tools: map[string]any{ - "github": map[string]any{ - "min-integrity": "approved", - }, - }, - } - - config := AWFCommandConfig{ - EngineName: "copilot", - WorkflowData: workflowData, - AllowedDomains: "github.com", - } - - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - - assert.NotContains(t, argsStr, "--difc-proxy-host", "Should not include --difc-proxy-host for old AWF") - assert.NotContains(t, argsStr, "--difc-proxy-ca-cert", "Should not include --difc-proxy-ca-cert for old AWF") - assert.NotContains(t, argsStr, "--enable-cli-proxy", "Should not include deprecated --enable-cli-proxy") - }) -} - -// TestAWFSupportsCliProxy tests the awfSupportsCliProxy version gate function. -func TestAWFSupportsCliProxy(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (uses default version)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (uses default version)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.25.17 supports CLI proxy flags (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.25.17"}, - want: true, - }, - { - name: "v0.26.0 supports CLI proxy flags", - firewallConfig: &FirewallConfig{Version: "v0.26.0"}, - want: true, - }, - { - name: "v0.27.0 supports CLI proxy flags", - firewallConfig: &FirewallConfig{Version: "v0.27.0"}, - want: true, - }, - { - name: "v0.25.16 does not support CLI proxy flags", - firewallConfig: &FirewallConfig{Version: "v0.25.16"}, - want: false, - }, - { - name: "v0.25.14 does not support CLI proxy flags", - firewallConfig: &FirewallConfig{Version: "v0.25.14"}, - want: false, - }, - { - name: "v0.1.0 does not support CLI proxy flags", - firewallConfig: &FirewallConfig{Version: "v0.1.0"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsCliProxy(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsCliProxy result") - }) - } -} - -// TestAWFSupportsAllowHostPorts tests the awfSupportsAllowHostPorts version gate function. -func TestAWFSupportsAllowHostPorts(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (uses default version)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (uses default version)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.25.24 supports --allow-host-ports (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.25.24"}, - want: true, - }, - { - name: "v0.26.0 supports --allow-host-ports", - firewallConfig: &FirewallConfig{Version: "v0.26.0"}, - want: true, - }, - { - name: "v0.25.23 does not support --allow-host-ports", - firewallConfig: &FirewallConfig{Version: "v0.25.23"}, - want: false, - }, - { - name: "v0.1.0 does not support --allow-host-ports", - firewallConfig: &FirewallConfig{Version: "v0.1.0"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsAllowHostPorts(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsAllowHostPorts result") - }) - } -} - -// TestAWFSupportsDockerHostPathPrefix tests the awfSupportsDockerHostPathPrefix version gate. -func TestAWFSupportsDockerHostPathPrefix(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (uses default version)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (uses default version)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.25.43 supports --docker-host-path-prefix (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.25.43"}, - want: true, - }, - { - name: "v0.25.42 does not support --docker-host-path-prefix", - firewallConfig: &FirewallConfig{Version: "v0.25.42"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsDockerHostPathPrefix(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsDockerHostPathPrefix result") - }) - } -} - -// TestArcDindDockerHostDetection exercises the generated shell snippet that probes -// DOCKER_HOST and conditionally sets the --docker-host passthrough value. -// NOTE: --docker-host-path-prefix is no longer emitted (removed for sysroot, gh-aw#34896). -func TestArcDindDockerHostDetection(t *testing.T) { - tests := []struct { - name string - dockerHost string - wantDockerHost bool - wantDockerHostV string - }{ - {"tcp://localhost:2375", "tcp://localhost:2375", true, "tcp://localhost:2375"}, - {"tcp://127.0.0.1:2375", "tcp://127.0.0.1:2375", true, "tcp://127.0.0.1:2375"}, - {"tcp://dind:2375 (K8s service name)", "tcp://dind:2375", true, "tcp://dind:2375"}, - {"tcp://172.30.0.5:2375 (pod IP)", "tcp://172.30.0.5:2375", true, "tcp://172.30.0.5:2375"}, - {"tcp://dind-sidecar.default.svc:2376", "tcp://dind-sidecar.default.svc:2376", true, "tcp://dind-sidecar.default.svc:2376"}, - {"unix socket (not tcp)", "unix:///var/run/docker.sock", false, ""}, - {"bare path", "/var/run/docker.sock", false, ""}, - {"empty (unset)", "", false, ""}, - } - - // Build the shell snippet from the constant (same code the compiler emits). - scriptTemplate := fmt.Sprintf(`#!/bin/bash -export DOCKER_HOST="%%s" -GH_AW_DOCKER_HOST="" -if [[ "${DOCKER_HOST:-}" =~ %s ]]; then - GH_AW_DOCKER_HOST="${DOCKER_HOST}" -fi -printf 'docker-host=%%%%s\n' "$GH_AW_DOCKER_HOST" -`, awfArcDindDockerHostRegex) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - script := fmt.Sprintf(scriptTemplate, tt.dockerHost) - cmd := exec.Command("bash", "-c", script) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "bash script should succeed, output: %s", string(out)) - - gotDockerHost := strings.TrimPrefix(strings.TrimSpace(string(out)), "docker-host=") - if tt.wantDockerHost { - assert.Equal(t, tt.wantDockerHostV, gotDockerHost, - "expected docker host passthrough value to be set for DOCKER_HOST=%s", tt.dockerHost) - } else { - assert.Empty(t, gotDockerHost, - "expected docker host passthrough value to NOT be set for DOCKER_HOST=%s", tt.dockerHost) - } - }) - } -} - -// TestAWFSupportsTokenSteering tests the awfSupportsTokenSteering version gate. -func TestAWFSupportsTokenSteering(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (uses default version)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (uses default version)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.25.44 supports token steering (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.25.44"}, - want: true, - }, - { - name: "v0.25.43 does not support token steering", - firewallConfig: &FirewallConfig{Version: "v0.25.43"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsTokenSteering(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsTokenSteering result") - }) - } -} - -// TestAWFSupportsChrootConfig tests the awfSupportsChrootConfig version gate. -func TestAWFSupportsChrootConfig(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (uses default version)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (uses default version)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.27.1 supports chroot config (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.27.1"}, - want: true, - }, - { - name: "v0.27.0 does not support chroot config", - firewallConfig: &FirewallConfig{Version: "v0.27.0"}, - want: false, - }, - { - name: "v0.25.44 (old) does not support chroot config", - firewallConfig: &FirewallConfig{Version: "v0.25.44"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsChrootConfig(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsChrootConfig result") - }) - } -} - -// TestAWFSupportsAPIProxyProviders tests the awfSupportsAPIProxyProviders version gate. -func TestAWFSupportsAPIProxyProviders(t *testing.T) { - tests := []struct { - name string - firewallConfig *FirewallConfig - want bool - }{ - { - name: "nil firewall config returns true (default version v0.27.43 meets minimum)", - firewallConfig: nil, - want: true, - }, - { - name: "empty version returns true (default version v0.27.43 meets minimum)", - firewallConfig: &FirewallConfig{}, - want: true, - }, - { - name: "latest returns true", - firewallConfig: &FirewallConfig{Version: "latest"}, - want: true, - }, - { - name: "v0.27.43 supports apiProxy.providers (exact minimum version)", - firewallConfig: &FirewallConfig{Version: "v0.27.43"}, - want: true, - }, - { - name: "v0.27.42 does not support apiProxy.providers (schema not present)", - firewallConfig: &FirewallConfig{Version: "v0.27.42"}, - want: false, - }, - { - name: "v0.27.41 does not support apiProxy.providers", - firewallConfig: &FirewallConfig{Version: "v0.27.41"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := awfSupportsAPIProxyProviders(tt.firewallConfig) - assert.Equal(t, tt.want, got, "awfSupportsAPIProxyProviders result") - }) - } -} - -// TestBuildAWFCommand_IncludesChrootInjectScript verifies that BuildAWFCommand -// includes the chroot injection script in the generated run step when the AWF -// version supports it. -func TestBuildAWFCommand_IncludesChrootInjectScript(t *testing.T) { - t.Run("chroot inject script present when AWF version supports it", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - EngineCommand: "copilot --prompt-file /tmp/prompt.txt", - LogFile: "/tmp/gh-aw/agent-stdio.log", - WorkflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - Version: string(constants.AWFChrootConfigMinVersion), - }, - }, - }, - } - command := BuildAWFCommand(config) - assert.Contains(t, command, awfArcDindChrootBinariesSourcePath, - "command should include the expected binariesSourcePath constant") - assert.Contains(t, command, awfArcDindChrootIdentityHome, - "command should include the expected identity.home constant") - assert.Contains(t, command, `node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"`, - "command should invoke the repository JavaScript helper for chroot config patching") - assert.NotContains(t, command, "python3 - <<'PY'", - "command should not inject an inline Python heredoc") - assert.Contains(t, command, awfArcDindDockerHostRegex, - "chroot inject script should reuse the DinD Docker host regex") - // Structural: the chroot injection must appear *after* the DOCKER_HOST guard, - // confirming it is nested inside the if-block and not emitted at top level. - dockerhostIdx := strings.Index(command, awfArcDindDockerHostRegex) - helperIdx := strings.Index(command, "patch_awf_chroot_config.cjs") - assert.Greater(t, helperIdx, dockerhostIdx, - "chroot injection must appear after the DOCKER_HOST guard in the generated script") - }) - - t.Run("chroot inject script absent when AWF version too old", func(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - EngineCommand: "copilot --prompt-file /tmp/prompt.txt", - LogFile: "/tmp/gh-aw/agent-stdio.log", - WorkflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Enabled: true, - Version: "v0.27.0", - }, - }, - }, - } - command := BuildAWFCommand(config) - assert.NotContains(t, command, "binariesSourcePath", - "command should NOT include chroot inject script for old AWF version") - }) -} - -func TestBuildModelsJSONPathExportScript(t *testing.T) { - t.Run("uses tmp path by default", func(t *testing.T) { - assert.Equal(t, `export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"`, buildModelsJSONPathExportScript(false)) - }) - - t.Run("uses runner temp path for arc-dind", func(t *testing.T) { - assert.Equal(t, `export GH_AW_MODELS_JSON_PATH="${RUNNER_TEMP}/gh-aw/models.json"`, buildModelsJSONPathExportScript(true)) - }) -} - -func TestRewriteArcDindPath(t *testing.T) { - t.Run("rewrites tmp gh-aw prefix", func(t *testing.T) { - assert.Equal(t, "${RUNNER_TEMP}/gh-aw/aw-prompts/prompt.txt", rewriteArcDindPath("/tmp/gh-aw/aw-prompts/prompt.txt")) - }) - - t.Run("rewrites multiple occurrences", func(t *testing.T) { - input := "/tmp/gh-aw/a /tmp/gh-aw/b" - expected := "${RUNNER_TEMP}/gh-aw/a ${RUNNER_TEMP}/gh-aw/b" - assert.Equal(t, expected, rewriteArcDindPath(input)) - }) - - t.Run("leaves unrelated paths unchanged", func(t *testing.T) { - assert.Equal(t, "/tmp/not-gh-aw/file.txt", rewriteArcDindPath("/tmp/not-gh-aw/file.txt")) - }) -} - -func TestRewriteArcDindEngineCommand(t *testing.T) { - command := "copilot --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt" - rewritten := rewriteArcDindEngineCommand(command) - - assert.Contains(t, rewritten, "export HOME=${RUNNER_TEMP}/gh-aw/home") - assert.Contains(t, rewritten, "copilot --prompt-file ${RUNNER_TEMP}/gh-aw/aw-prompts/prompt.txt") -} - -func TestGetGeminiAPITarget(t *testing.T) { - tests := []struct { - name string - workflowData *WorkflowData - engineName string - expected string - }{ - { - name: "returns default target for gemini engine with no custom URL", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - ID: "gemini", - }, - }, - engineName: "gemini", - expected: "generativelanguage.googleapis.com", - }, - { - name: "custom GEMINI_API_BASE_URL takes precedence over default", - workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ - ID: "gemini", - Env: map[string]string{ - "GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1", - }, - }, - }, - engineName: "gemini", - expected: "gemini-proxy.internal.company.com", + name: "custom GEMINI_API_BASE_URL takes precedence over default", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "gemini", + Env: map[string]string{ + "GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1", + }, + }, + }, + engineName: "gemini", + expected: "gemini-proxy.internal.company.com", }, { name: "returns empty for non-gemini engine without custom URL", @@ -2247,6 +1000,7 @@ func TestGetGeminiAPITarget(t *testing.T) { // TestAWFGeminiAPITargetFlags tests that BuildAWFConfigJSON includes --gemini target // for the Gemini engine with default and custom endpoints, while base paths remain CLI flags. + func TestAWFGeminiAPITargetFlags(t *testing.T) { t.Run("includes default gemini target in config JSON for gemini engine", func(t *testing.T) { workflowData := &WorkflowData{ @@ -2373,6 +1127,7 @@ func TestAWFGeminiAPITargetFlags(t *testing.T) { // TestGeminiEngineIncludesGeminiAPITarget tests that the Gemini engine execution // step includes the gemini API target in the JSON config when firewall is enabled. + func TestGeminiEngineIncludesGeminiAPITarget(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", @@ -2401,297 +1156,3 @@ func TestGeminiEngineIncludesGeminiAPITarget(t *testing.T) { assert.Contains(t, stepContent, "generativelanguage.googleapis.com", "Should include default Gemini API hostname") assert.NotContains(t, stepContent, "--gemini-api-target", "Should not emit --gemini-api-target as CLI flag") } - -func TestBuildAWFImageTagWithDigests(t *testing.T) { - t.Run("includes digest metadata for known firewall images", func(t *testing.T) { - imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") - tag := buildAWFImageTagWithDigests(imageTag, nil) - - assert.Contains(t, tag, imageTag, "should keep original AWF tag") - assert.Contains(t, tag, "squid=sha256:", "should include squid digest metadata") - assert.Contains(t, tag, "agent=sha256:", "should include agent digest metadata") - assert.Contains(t, tag, "api-proxy=sha256:", "should include api-proxy digest metadata") - assert.Contains(t, tag, "cli-proxy=sha256:", "should include cli-proxy digest metadata") - }) - - t.Run("leaves tag unchanged when digests are unavailable", func(t *testing.T) { - tag := buildAWFImageTagWithDigests("0.0.1", nil) - assert.Equal(t, "0.0.1", tag, "should not append digest metadata when no pins are available") - }) - - t.Run("includes build-tools digest for arc-dind topology", func(t *testing.T) { - imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") - buildToolsImage := constants.DefaultFirewallRegistry + "/build-tools:" + imageTag - cache := &ActionCache{ContainerPins: make(map[string]ContainerPin)} - cache.SetContainerPin( - buildToolsImage, - "sha256:1111111111111111111111111111111111111111111111111111111111111111", - buildToolsImage+"@sha256:1111111111111111111111111111111111111111111111111111111111111111", - ) - workflowData := &WorkflowData{ - RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, - ActionCache: cache, - } - tag := buildAWFImageTagWithDigests(imageTag, workflowData) - - assert.Contains(t, tag, "build-tools=sha256:", "should include build-tools digest metadata for arc-dind topology") - }) - - t.Run("excludes build-tools digest without arc-dind topology", func(t *testing.T) { - imageTag := strings.TrimPrefix(string(constants.DefaultFirewallVersion), "v") - tag := buildAWFImageTagWithDigests(imageTag, nil) - - assert.NotContains(t, tag, "build-tools=", "should not include build-tools digest metadata without arc-dind topology") - }) -} - -func TestBuildAWFArgs_ImageTagIncludesDigests(t *testing.T) { - // Use the default firewall version so this test tracks pin/version updates. - config := AWFCommandConfig{ - EngineName: "copilot", - AllowedDomains: "github.com", - WorkflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot"}, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true, Version: string(constants.DefaultFirewallVersion)}, - }, - }, - } - - // When the AWF version supports --config (default), --image-tag moves to the JSON config file. - // Verify the config file JSON contains the image tag with digest metadata. - awfConfigJSON, err := BuildAWFConfigJSON(config) - require.NoError(t, err, "BuildAWFConfigJSON should not error") - assert.Contains(t, awfConfigJSON, "imageTag", "expected imageTag in AWF config JSON") - assert.Contains(t, awfConfigJSON, "squid=sha256:", "expected squid digest metadata in AWF config JSON") - assert.Contains(t, awfConfigJSON, "agent=sha256:", "expected agent digest metadata in AWF config JSON") - assert.Contains(t, awfConfigJSON, "api-proxy=sha256:", "expected api-proxy digest metadata in AWF config JSON") - - // --image-tag should NOT appear in the CLI args (it's in the config file). - args := BuildAWFArgs(config) - argsStr := strings.Join(args, " ") - assert.NotContains(t, argsStr, "--image-tag", "expected --image-tag to be absent from CLI args when config file is used") -} - -// TestMainAgentRunUsesStandardCreditsExpressionNotDetectionExpression verifies that -// a standard (non-detection) main-agent run emits the main-agent credits expression -// (vars.GH_AW_DEFAULT_MAX_AI_CREDITS) and not the detection-specific one, so a future -// refactor that accidentally sets IsDetectionRun on main-agent data will be caught. -func TestMainAgentRunUsesStandardCreditsExpressionNotDetectionExpression(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - ID: "claude", - // MaxAICredits is zero (not set in frontmatter) to trigger runtime expression injection. - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{Enabled: true}, - }, - // IsDetectionRun is false by default — this is a main-agent run. - } - - engine := NewClaudeEngine() - steps := engine.GetExecutionSteps(workflowData, "test.log") - require.NotEmpty(t, steps, "should produce execution steps") - - stepContent := strings.Join(steps[0], "\n") - - assert.Contains(t, stepContent, "vars.GH_AW_DEFAULT_MAX_AI_CREDITS", - "main-agent run should use standard credits expression") - assert.NotContains(t, stepContent, "vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS", - "main-agent run must not use detection credits expression") -} - -// TestGetAWFCommandPrefixNetworkIsolation tests that GetAWFCommandPrefix returns the correct -// command based on security mode: strict (default, no sudo) or legacy (sudo -E awf). -func TestGetAWFCommandPrefixNetworkIsolation(t *testing.T) { - t.Run("returns awf (no sudo) when sudo is false (network isolation mode)", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - NetworkIsolation: true, - }, - }, - } - cmd := GetAWFCommandPrefix(workflowData) - assert.Equal(t, "awf", cmd, "Should return rootless 'awf' when sudo is false (network isolation mode)") - assert.NotContains(t, cmd, "sudo", "Should not contain sudo when sudo is false (network isolation mode)") - }) - - t.Run("returns awf (no sudo) by default in strict security mode", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - NetworkIsolation: false, - }, - }, - } - cmd := GetAWFCommandPrefix(workflowData) - assert.Equal(t, "awf", cmd, "Should return 'awf' (no sudo) in strict security mode even with sudo: true") - }) - - t.Run("returns awf (no sudo) when no sandbox config is set", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - } - cmd := GetAWFCommandPrefix(workflowData) - assert.Equal(t, "awf", cmd, "Should return 'awf' (no sudo) when there is no sandbox config") - }) - - t.Run("returns sudo -E awf when legacy-security is enabled", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - LegacySecurity: true, - }, - }, - } - cmd := GetAWFCommandPrefix(workflowData) - assert.Equal(t, "sudo -E awf", cmd, "Should return 'sudo -E awf' when legacy-security is enabled") - }) - - t.Run("custom command takes precedence over sudo setting", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - NetworkIsolation: true, - Command: "custom-awf", - }, - }, - } - cmd := GetAWFCommandPrefix(workflowData) - assert.Equal(t, "custom-awf", cmd, "Custom command should take precedence over sudo rootless mode") - }) -} - -func TestBuildAWFArgs_LegacySecurityVersionGuard(t *testing.T) { - t.Run("emits --legacy-security when AWF version supports it", func(t *testing.T) { - config := AWFCommandConfig{ - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - LegacySecurity: true, - }, - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Version: "0.27.32", - }, - }, - }, - EngineName: "copilot", - } - args := BuildAWFArgs(config) - assert.Contains(t, args, "--legacy-security", "Should emit --legacy-security for AWF >= v0.27.32") - }) - - t.Run("skips --legacy-security when AWF version is too old", func(t *testing.T) { - config := AWFCommandConfig{ - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - LegacySecurity: true, - }, - }, - NetworkPermissions: &NetworkPermissions{ - Firewall: &FirewallConfig{ - Version: "0.27.30", - }, - }, - }, - EngineName: "copilot", - } - args := BuildAWFArgs(config) - assert.NotContains(t, args, "--legacy-security", "Should NOT emit --legacy-security for AWF < v0.27.32") - // But should still emit --enable-host-access for backward compat - assert.Contains(t, args, "--enable-host-access", "Should still emit --enable-host-access for legacy mode") - }) -} - -func TestBuildAWFCommand_ServicePortsRequireLegacy(t *testing.T) { - t.Run("emits --allow-host-service-ports when legacy-security is enabled", func(t *testing.T) { - config := AWFCommandConfig{ - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - ServicePortExpressions: "${{ job.services.db.ports['5432'] }}", - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - LegacySecurity: true, - }, - }, - }, - EngineName: "copilot", - EngineCommand: "copilot-agent", - } - cmd := BuildAWFCommand(config) - assert.Contains(t, cmd, "--allow-host-service-ports", "Should emit --allow-host-service-ports in legacy mode") - }) - - t.Run("skips --allow-host-service-ports in strict mode", func(t *testing.T) { - config := AWFCommandConfig{ - WorkflowData: &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ID: "copilot"}, - ServicePortExpressions: "${{ job.services.db.ports['5432'] }}", - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", - }, - }, - }, - EngineName: "copilot", - EngineCommand: "copilot-agent", - } - cmd := BuildAWFCommand(config) - assert.NotContains(t, cmd, "--allow-host-service-ports", "Should NOT emit --allow-host-service-ports in strict mode") - }) -} - -func TestBuildAWFCommand_ArcDindPreCreatesMountDirs(t *testing.T) { - config := AWFCommandConfig{ - EngineName: "copilot", - EngineCommand: "copilot run", - LogFile: "/tmp/log.txt", - PathSetup: "export PATH=/usr/bin:$PATH", - WorkflowData: &WorkflowData{ - Name: "Test", - AI: "copilot", - MarkdownContent: "test", - RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ID: "awf"}, - }, - }, - } - - command := BuildAWFCommand(config) - - // Verify mount source directories are pre-created before AWF invocation - assert.Contains(t, command, `mkdir -p "${RUNNER_TEMP}/gh-aw/home" "${RUNNER_TEMP}/gh-aw/sandbox/agent"`, - "should pre-create rw mount source directories for arc-dind") - - // Verify the mounts themselves are present - assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/home:${RUNNER_TEMP}/gh-aw/home:rw"`) - assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/sandbox/agent:${RUNNER_TEMP}/gh-aw/sandbox/agent:rw"`) -} From db5d5d454bd9f6aee9142e8211292572292b7725 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 10 Aug 2026 14:22:00 -0700 Subject: [PATCH 3/8] Refine unified enclave configuration Move enclaves under sandbox as a discriminated executor array, preserve shared repository sensitivity, and emit the same shape to AWF. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 245af6c2-3f8d-47e0-b99a-e0144e107a0d --- docs/src/content/docs/reference/enclaves.md | 32 +- pkg/parser/schemas/main_workflow_schema.json | 208 ++++++------ pkg/workflow/awf_config.go | 2 +- pkg/workflow/enclaves.go | 253 +++++++-------- pkg/workflow/enclaves_test.go | 116 +++++-- .../frontmatter_extraction_security.go | 41 ++- pkg/workflow/frontmatter_serialization.go | 4 - pkg/workflow/frontmatter_types.go | 1 - pkg/workflow/sandbox.go | 2 + pkg/workflow/schemas/awf-config.schema.json | 306 +++++++++++------- pkg/workflow/workflow_builder.go | 8 +- pkg/workflow/workflow_data.go | 2 +- 12 files changed, 546 insertions(+), 429 deletions(-) diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md index 07bad36a64d..2da06737698 100644 --- a/docs/src/content/docs/reference/enclaves.md +++ b/docs/src/content/docs/reference/enclaves.md @@ -3,31 +3,31 @@ title: Private repository enclaves description: Configure unified AWF script and agent enclaves through the trusted MCP gateway. --- -The top-level `enclaves` field enables finite-disclosure access to approved private repositories. The compiler registers only the enabled `enclave_run_script` and `enclave_run_agent` tools on the `awf-enclave` MCP route. +The `sandbox.enclaves` array enables finite-disclosure access to approved private repositories. The compiler registers `enclave_run_script` or `enclave_run_agent` from the entry types present on the `awf-enclave` MCP route. Omit the array to disable enclaves. Enclaves require AWF network isolation. Configure `sandbox.agent.sudo: false` (or the `docker-sbx` runtime) so the compiler launches mcpg in bridge mode and AWF can attach it to the isolated topology. ```yaml -enclaves: - enabled: true - private-repos: - - repo: octo-org/private-service - sensitivity: confidential - executors: - script: - enabled: true - timeout: 45 - agent: - enabled: true - model: gpt-5 - timeout: 180 - sandbox: agent: id: awf sudo: false + enclaves: + - type: script + repositories: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 + - type: agent + repositories: + - repo: octo-org/private-service + sensitivity: confidential + model: gpt-5 + timeout: 180 ``` -The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available and sets its tool timeout to the longest enabled executor timeout plus 30 seconds. +Each type can appear at most once. When the same repository appears in both entries, its sensitivity must match because its information budget is shared across executor types. AWF fixes the script enclave network and interpreter and the agent enclave network internally; workflows cannot override those security invariants. + +The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available and sets its tool timeout to the longest configured or default executor timeout plus 30 seconds. This compiler contract depends on the unified enclave implementation from `github/gh-aw-firewall#6992`. Until that change is available in an AWF release, pinning an older AWF version will not provide the enclave server. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 011248d3f40..0baeebe103a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2791,121 +2791,6 @@ } ] }, - "enclaves": { - "type": "object", - "description": "Unified AWF-owned private-repository script and agent enclaves. Enabled executors are exposed only through the compiler-launched MCP gateway.", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "private-repos": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["repo", "sensitivity"], - "properties": { - "repo": { - "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "confidential", "sealed"] - } - } - } - }, - "executors": { - "type": "object", - "additionalProperties": false, - "properties": { - "script": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": false }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { "type": "string", "minLength": 1, "maxLength": 500 }, - "network": { "const": "none", "default": "none" }, - "interpreter": { "const": "python3", "default": "python3" }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, - "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, - "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } - } - }, - "agent": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": false }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { "type": "string", "minLength": 1, "maxLength": 500 }, - "network": { "const": "api-proxy-only", "default": "api-proxy-only" }, - "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, - "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, - "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, - "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, - "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, - "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, - "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } - }, - "if": { - "properties": { "enabled": { "const": true } }, - "required": ["enabled"] - }, - "then": { "required": ["model"] } - } - } - } - }, - "if": { - "properties": { "enabled": { "const": true } }, - "required": ["enabled"] - }, - "then": { - "required": ["private-repos", "executors"], - "properties": { - "executors": { - "anyOf": [ - { - "required": ["script"], - "properties": { - "script": { - "required": ["enabled"], - "properties": { "enabled": { "const": true } } - } - } - }, - { - "required": ["agent"], - "properties": { - "agent": { - "required": ["enabled"], - "properties": { "enabled": { "const": true } } - } - } - } - ] - } - } - } - }, "runner": { "type": "object", "description": "Runner topology configuration. Tells gh-aw and AWF what kind of runner environment the workflow targets, so they can activate topology-specific behaviors automatically (split-filesystem handling, network isolation, sysroot images, tool cache redirection). The runner.topology key is the single stable contract between gh-aw and AWF for runner environment detection.", @@ -3540,8 +3425,99 @@ }, { "type": "object", - "description": "Object format for full sandbox configuration with agent and mcp options", + "description": "Object format for full sandbox configuration with agent, enclave, and MCP gateway options", "properties": { + "enclaves": { + "type": "array", + "description": "AWF-owned private-repository executors exposed only through the compiler-launched MCP gateway. Omit this field to disable enclaves.", + "minItems": 1, + "maxItems": 2, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "repositories"], + "properties": { + "type": { "const": "script" }, + "repositories": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "repositories", "model"], + "properties": { + "type": { "const": "agent" }, + "repositories": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, + "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, + "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, + "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, + "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + } + } + ] + } + }, "type": { "type": "string", "enum": ["default", "awf"], diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c8ddcec8ce8..3639d22b694 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -171,7 +171,7 @@ type AWFConfigFile struct { BoundedQueries *AWFBoundedQueriesConfig `json:"boundedQueries,omitempty"` // Enclaves configures the unified AWF-owned script and agent enclave subsystem. - Enclaves map[string]any `json:"enclaves,omitempty"` + Enclaves []map[string]any `json:"enclaves,omitempty"` // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index 042885a574d..2375a2c78e8 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -26,95 +26,72 @@ const ( var enclaveRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) // EnclavesConfig configures AWF-owned, finite-disclosure private repository executors. -type EnclavesConfig struct { - Enabled bool `json:"enabled,omitempty"` - PrivateRepos []*EnclavePrivateRepo `json:"private-repos,omitempty"` - Executors *EnclaveExecutorsConfig `json:"executors,omitempty"` -} +// Each executor type may appear at most once. +type EnclavesConfig []*EnclaveConfig -type EnclavePrivateRepo struct { +type EnclaveRepository struct { Repo string `json:"repo"` Sensitivity string `json:"sensitivity"` } -type EnclaveExecutorsConfig struct { - Script *ScriptEnclaveExecutorConfig `json:"script,omitempty"` - Agent *AgentEnclaveExecutorConfig `json:"agent,omitempty"` -} - -type ScriptEnclaveExecutorConfig struct { - Enabled bool `json:"enabled,omitempty"` - Runtime string `json:"runtime,omitempty"` - Image string `json:"image,omitempty"` - Network string `json:"network,omitempty"` - Interpreter string `json:"interpreter,omitempty"` - Timeout int `json:"timeout,omitempty"` - MemoryLimit string `json:"memory-limit,omitempty"` - CPULimit string `json:"cpu-limit,omitempty"` - PIDsLimit int `json:"pids-limit,omitempty"` - TmpfsLimit string `json:"tmpfs-limit,omitempty"` - MaxOutputBytes int `json:"max-output-bytes,omitempty"` - MaxScriptBytes int `json:"max-script-bytes,omitempty"` - MaxInvocations int `json:"max-invocations,omitempty"` -} - -type AgentEnclaveExecutorConfig struct { - Enabled bool `json:"enabled,omitempty"` - Runtime string `json:"runtime,omitempty"` - Image string `json:"image,omitempty"` - Network string `json:"network,omitempty"` - Engine string `json:"engine,omitempty"` - Profile string `json:"profile,omitempty"` - Model string `json:"model,omitempty"` - Timeout int `json:"timeout,omitempty"` - MemoryLimit string `json:"memory-limit,omitempty"` - CPULimit string `json:"cpu-limit,omitempty"` - PIDsLimit int `json:"pids-limit,omitempty"` - TmpfsLimit string `json:"tmpfs-limit,omitempty"` - MaxOutputBytes int `json:"max-output-bytes,omitempty"` - MaxTaskBytes int `json:"max-task-bytes,omitempty"` - MaxInvocations int `json:"max-invocations,omitempty"` - MaxModelRequests int `json:"max-model-requests,omitempty"` - MaxModelTokens int `json:"max-model-tokens,omitempty"` +type EnclaveConfig struct { + Type string `json:"type"` + Repositories []*EnclaveRepository `json:"repositories"` + Runtime string `json:"runtime,omitempty"` + Image string `json:"image,omitempty"` + Timeout int `json:"timeout,omitempty"` + MemoryLimit string `json:"memory-limit,omitempty"` + CPULimit string `json:"cpu-limit,omitempty"` + PIDsLimit int `json:"pids-limit,omitempty"` + TmpfsLimit string `json:"tmpfs-limit,omitempty"` + MaxOutputBytes int `json:"max-output-bytes,omitempty"` + MaxScriptBytes int `json:"max-script-bytes,omitempty"` + MaxInvocations int `json:"max-invocations,omitempty"` + Engine string `json:"engine,omitempty"` + Profile string `json:"profile,omitempty"` + Model string `json:"model,omitempty"` + MaxTaskBytes int `json:"max-task-bytes,omitempty"` + MaxModelRequests int `json:"max-model-requests,omitempty"` + MaxModelTokens int `json:"max-model-tokens,omitempty"` } func enclavesEnabled(workflowData *WorkflowData) bool { - return workflowData != nil && workflowData.Enclaves != nil && workflowData.Enclaves.Enabled + return workflowData != nil && len(workflowData.Enclaves) > 0 } func enabledEnclaveTools(workflowData *WorkflowData) []string { - if !enclavesEnabled(workflowData) || workflowData.Enclaves.Executors == nil { - return nil - } var tools []string - if script := workflowData.Enclaves.Executors.Script; script != nil && script.Enabled { - tools = append(tools, "enclave_run_script") - } - if agent := workflowData.Enclaves.Executors.Agent; agent != nil && agent.Enabled { - tools = append(tools, "enclave_run_agent") + for _, enclave := range workflowData.Enclaves { + if enclave == nil { + continue + } + switch enclave.Type { + case "script": + tools = append(tools, "enclave_run_script") + case "agent": + tools = append(tools, "enclave_run_agent") + } } return tools } func enclaveToolTimeout(workflowData *WorkflowData) int { maxTimeout := 0 - if !enclavesEnabled(workflowData) || workflowData.Enclaves.Executors == nil { - return 0 - } - if script := workflowData.Enclaves.Executors.Script; script != nil && script.Enabled { - timeout := script.Timeout - if timeout == 0 { - timeout = defaultScriptEnclaveTimeout + for _, enclave := range workflowData.Enclaves { + if enclave == nil { + continue } - maxTimeout = max(maxTimeout, timeout) - } - if agent := workflowData.Enclaves.Executors.Agent; agent != nil && agent.Enabled { - timeout := agent.Timeout - if timeout == 0 { + timeout := enclave.Timeout + if timeout == 0 && enclave.Type == "script" { + timeout = defaultScriptEnclaveTimeout + } else if timeout == 0 && enclave.Type == "agent" { timeout = defaultAgentEnclaveTimeout } maxTimeout = max(maxTimeout, timeout) } + if maxTimeout == 0 { + return 0 + } return maxTimeout + 30 } @@ -123,99 +100,93 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { return nil } if !isAWFNetworkIsolationEnabled(workflowData) { - return errors.New("enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") + return errors.New("sandbox.enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") } if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.BoundedQueries != nil { - return errors.New("enclaves cannot be combined with tools.github.bounded-queries") - } - config := workflowData.Enclaves - if len(config.PrivateRepos) == 0 { - return errors.New("enclaves.private-repos must contain at least one repository when enclaves is enabled") + return errors.New("sandbox.enclaves cannot be combined with tools.github.bounded-queries") } - seen := make(map[string]struct{}, len(config.PrivateRepos)) - for i, repo := range config.PrivateRepos { - if repo == nil { - return fmt.Errorf("enclaves.private-repos[%d] must be an object", i) + seenTypes := make(map[string]struct{}, len(workflowData.Enclaves)) + repositorySensitivities := make(map[string]string) + for i, enclave := range workflowData.Enclaves { + if enclave == nil { + return fmt.Errorf("sandbox.enclaves[%d] must be an object", i) } - parts := strings.SplitN(repo.Repo, "/", 2) - if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { - return fmt.Errorf("enclaves.private-repos[%d].repo must be a bare owner/repository slug", i) + if enclave.Type != "script" && enclave.Type != "agent" { + return fmt.Errorf("sandbox.enclaves[%d].type must be script or agent", i) } - - key := strings.ToLower(repo.Repo) - if _, ok := seen[key]; ok { - return fmt.Errorf("enclaves.private-repos contains duplicate repository %q", repo.Repo) + if _, ok := seenTypes[enclave.Type]; ok { + return fmt.Errorf("sandbox.enclaves contains duplicate executor type %q", enclave.Type) } - seen[key] = struct{}{} - switch repo.Sensitivity { - case "public", "internal", "confidential", "sealed": - default: - return fmt.Errorf("enclaves.private-repos[%d].sensitivity must be public, internal, confidential, or sealed", i) + seenTypes[enclave.Type] = struct{}{} + if enclave.Type == "agent" && enclave.Model == "" { + return fmt.Errorf("sandbox.enclaves[%d].model is required for agent enclaves", i) + } + if len(enclave.Repositories) == 0 { + return fmt.Errorf("sandbox.enclaves[%d].repositories must contain at least one repository", i) + } + seenInEnclave := make(map[string]struct{}, len(enclave.Repositories)) + for j, repo := range enclave.Repositories { + if repo == nil { + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d] must be an object", i, j) + } + parts := strings.SplitN(repo.Repo, "/", 2) + if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].repo must be a bare owner/repository slug", i, j) + } + key := strings.ToLower(repo.Repo) + if _, ok := seenInEnclave[key]; ok { + return fmt.Errorf("sandbox.enclaves[%d].repositories contains duplicate repository %q", i, repo.Repo) + } + seenInEnclave[key] = struct{}{} + switch repo.Sensitivity { + case "public", "internal", "confidential", "sealed": + default: + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].sensitivity must be public, internal, confidential, or sealed", i, j) + } + if sensitivity, ok := repositorySensitivities[key]; ok && sensitivity != repo.Sensitivity { + return fmt.Errorf("repository %q must use the same sensitivity across enclave types", repo.Repo) + } + repositorySensitivities[key] = repo.Sensitivity } - } - if len(enabledEnclaveTools(workflowData)) == 0 { - return errors.New("enclaves.executors must enable at least one of script or agent") - } - if agent := config.Executors.Agent; agent != nil && agent.Enabled && agent.Model == "" { - return errors.New("enclaves.executors.agent.model is required when the agent executor is enabled") } return nil } -func buildAWFEnclavesConfig(config *EnclavesConfig) map[string]any { - if config == nil || !config.Enabled { +func buildAWFEnclavesConfig(config EnclavesConfig) []map[string]any { + if len(config) == 0 { return nil } - result := map[string]any{"enabled": true} - privateRepos := make([]map[string]any, 0, len(config.PrivateRepos)) - for _, repo := range config.PrivateRepos { - privateRepos = append(privateRepos, map[string]any{ - "repo": repo.Repo, "sensitivity": repo.Sensitivity, - }) - } - result["privateRepos"] = privateRepos - executors := make(map[string]any) - if config.Executors != nil { - if script := config.Executors.Script; script != nil { - values := map[string]any{"enabled": script.Enabled} - addEnclaveString(values, "runtime", script.Runtime) - addEnclaveString(values, "image", script.Image) - addEnclaveString(values, "network", script.Network) - addEnclaveString(values, "interpreter", script.Interpreter) - addEnclaveInt(values, "timeout", script.Timeout) - addEnclaveString(values, "memoryLimit", script.MemoryLimit) - addEnclaveString(values, "cpuLimit", script.CPULimit) - addEnclaveInt(values, "pidsLimit", script.PIDsLimit) - addEnclaveString(values, "tmpfsLimit", script.TmpfsLimit) - addEnclaveInt(values, "maxOutputBytes", script.MaxOutputBytes) - addEnclaveInt(values, "maxScriptBytes", script.MaxScriptBytes) - addEnclaveInt(values, "maxInvocations", script.MaxInvocations) - executors["script"] = values + result := make([]map[string]any, 0, len(config)) + for _, enclave := range config { + values := map[string]any{"type": enclave.Type} + repositories := make([]map[string]any, 0, len(enclave.Repositories)) + for _, repo := range enclave.Repositories { + repositories = append(repositories, map[string]any{"repo": repo.Repo, "sensitivity": repo.Sensitivity}) } - if agent := config.Executors.Agent; agent != nil { - values := map[string]any{"enabled": agent.Enabled} - addEnclaveString(values, "runtime", agent.Runtime) - addEnclaveString(values, "image", agent.Image) - addEnclaveString(values, "network", agent.Network) - addEnclaveString(values, "engine", agent.Engine) - addEnclaveString(values, "profile", agent.Profile) - addEnclaveString(values, "model", agent.Model) - addEnclaveInt(values, "timeout", agent.Timeout) - addEnclaveString(values, "memoryLimit", agent.MemoryLimit) - addEnclaveString(values, "cpuLimit", agent.CPULimit) - addEnclaveInt(values, "pidsLimit", agent.PIDsLimit) - addEnclaveString(values, "tmpfsLimit", agent.TmpfsLimit) - addEnclaveInt(values, "maxOutputBytes", agent.MaxOutputBytes) - addEnclaveInt(values, "maxTaskBytes", agent.MaxTaskBytes) - addEnclaveInt(values, "maxInvocations", agent.MaxInvocations) - addEnclaveInt(values, "maxModelRequests", agent.MaxModelRequests) - addEnclaveInt(values, "maxModelTokens", agent.MaxModelTokens) - executors["agent"] = values + values["repositories"] = repositories + addEnclaveString(values, "runtime", enclave.Runtime) + addEnclaveString(values, "image", enclave.Image) + addEnclaveInt(values, "timeout", enclave.Timeout) + addEnclaveString(values, "memoryLimit", enclave.MemoryLimit) + addEnclaveString(values, "cpuLimit", enclave.CPULimit) + addEnclaveInt(values, "pidsLimit", enclave.PIDsLimit) + addEnclaveString(values, "tmpfsLimit", enclave.TmpfsLimit) + addEnclaveInt(values, "maxOutputBytes", enclave.MaxOutputBytes) + addEnclaveInt(values, "maxInvocations", enclave.MaxInvocations) + if enclave.Type == "script" { + addEnclaveInt(values, "maxScriptBytes", enclave.MaxScriptBytes) + } else { + addEnclaveString(values, "engine", enclave.Engine) + addEnclaveString(values, "profile", enclave.Profile) + addEnclaveString(values, "model", enclave.Model) + addEnclaveInt(values, "maxTaskBytes", enclave.MaxTaskBytes) + addEnclaveInt(values, "maxModelRequests", enclave.MaxModelRequests) + addEnclaveInt(values, "maxModelTokens", enclave.MaxModelTokens) } + result = append(result, values) } - result["executors"] = executors return result } diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go index 5993ca0e6c9..2af9439cab8 100644 --- a/pkg/workflow/enclaves_test.go +++ b/pkg/workflow/enclaves_test.go @@ -24,27 +24,26 @@ func enclaveWorkflowData(script, agent bool, scriptTimeout, agentTimeout int) *W NetworkPermissions: &NetworkPermissions{ Firewall: &FirewallConfig{Enabled: true}, }, - Enclaves: &EnclavesConfig{ - Enabled: true, - PrivateRepos: []*EnclavePrivateRepo{{ - Repo: "octo-org/private-service", Sensitivity: "confidential", - }}, - Executors: &EnclaveExecutorsConfig{}, - }, } if script { - data.Enclaves.Executors.Script = &ScriptEnclaveExecutorConfig{ - Enabled: true, Timeout: scriptTimeout, - } + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Type: "script", Timeout: scriptTimeout, Repositories: enclaveTestRepositories(), + }) } if agent { - data.Enclaves.Executors.Agent = &AgentEnclaveExecutorConfig{ - Enabled: true, Model: "gpt-5", Timeout: agentTimeout, - } + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Type: "agent", Model: "gpt-5", Timeout: agentTimeout, Repositories: enclaveTestRepositories(), + }) } return data } +func enclaveTestRepositories() []*EnclaveRepository { + return []*EnclaveRepository{{ + Repo: "octo-org/private-service", Sensitivity: "confidential", + }} +} + func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { tests := []struct { name string @@ -66,8 +65,7 @@ func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { }) } - disabled := enclaveWorkflowData(true, true, 30, 120) - disabled.Enclaves.Enabled = false + disabled := enclaveWorkflowData(false, false, 0, 0) assert.Empty(t, enabledEnclaveTools(disabled)) assert.NotContains(t, collectMCPTools(disabled), enclaveMCPServerName) } @@ -92,6 +90,64 @@ func TestValidateEnclavesRejectsBoundedQueries(t *testing.T) { assert.Contains(t, err.Error(), "cannot be combined with tools.github.bounded-queries") } +func TestValidateEnclavesRejectsDuplicateTypes(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Type: "script", Repositories: enclaveTestRepositories(), + }) + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), `duplicate executor type "script"`) +} + +func TestValidateEnclavesRequiresConsistentRepositorySensitivity(t *testing.T) { + data := enclaveWorkflowData(true, true, 30, 120) + data.Enclaves[1].Repositories[0].Sensitivity = "sealed" + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "must use the same sensitivity across enclave types") +} + +func TestValidateEnclavesRequiresAgentModelOnly(t *testing.T) { + data := enclaveWorkflowData(false, true, 0, 120) + data.Enclaves[0].Model = "" + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "model is required for agent enclaves") + + script := enclaveWorkflowData(true, false, 30, 0) + assert.NoError(t, validateEnclavesConfig(script)) +} + +func TestExtractEnclavesPreservesLegacySandboxConfig(t *testing.T) { + compiler := NewCompiler() + config := compiler.extractSandboxConfig(map[string]any{ + "sandbox": map[string]any{ + "type": "awf", + "config": map[string]any{ + "filesystem": map[string]any{ + "denyRead": []any{"/private"}, + }, + }, + "enclaves": []any{ + map[string]any{ + "type": "script", + "repositories": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + }, + }, + }, + }) + require.NotNil(t, config) + assert.Equal(t, SandboxTypeAWF, config.Type) + require.NotNil(t, config.Config) + require.NotNil(t, config.Config.Filesystem) + assert.Equal(t, []string{"/private"}, config.Config.Filesystem.DenyRead) + require.Len(t, config.Enclaves, 1) + assert.Equal(t, "script", config.Enclaves[0].Type) +} + func TestBuildAWFConfigJSONEnclaves(t *testing.T) { data := enclaveWorkflowData(true, true, 45, 180) configJSON, err := BuildAWFConfigJSON(AWFCommandConfig{ @@ -101,10 +157,17 @@ func TestBuildAWFConfigJSONEnclaves(t *testing.T) { var config map[string]any require.NoError(t, json.Unmarshal([]byte(configJSON), &config)) - enclaves := config["enclaves"].(map[string]any) - executors := enclaves["executors"].(map[string]any) - assert.InDelta(t, 45, executors["script"].(map[string]any)["timeout"], 0) - assert.Equal(t, "gpt-5", executors["agent"].(map[string]any)["model"]) + enclaves := config["enclaves"].([]any) + script := enclaves[0].(map[string]any) + agent := enclaves[1].(map[string]any) + assert.Equal(t, "script", script["type"]) + assert.InDelta(t, 45, script["timeout"], 0) + assert.Equal(t, "agent", agent["type"]) + assert.Equal(t, "gpt-5", agent["model"]) + assert.Contains(t, script, "repositories") + assert.NotContains(t, script, "enabled") + assert.NotContains(t, script, "network") + assert.NotContains(t, script, "interpreter") assert.Equal(t, []any{"awmg-mcpg"}, config["network"].(map[string]any)["topologyAttach"]) assert.NotContains(t, configJSON, "boundedQueries") assert.NotContains(t, configJSON, "boundedAgents") @@ -152,14 +215,11 @@ sandbox: id: awf sudo: false version: latest -enclaves: - enabled: true - private-repos: - - repo: octo-org/private-service - sensitivity: confidential - executors: - script: - enabled: true + enclaves: + - type: script + repositories: + - repo: octo-org/private-service + sensitivity: confidential timeout: 45 --- @@ -178,6 +238,8 @@ Use the enclave script executor. require.Greater(t, gateway, -1) require.Greater(t, awf, -1) assert.Less(t, gateway, awf) + assert.Contains(t, lock, `"awf-enclave"`) + assert.Contains(t, lock, `\"enclaves\":[{\"repositories\":[{\"repo\":\"octo-org/private-service\",\"sensitivity\":\"confidential\"}],\"timeout\":45,\"type\":\"script\"}]`) assert.NotContains(t, lock, "Start Enclave MCP") assert.NotContains(t, lock, "start_enclave") } diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 6747b154435..fa3c994e584 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -1,6 +1,10 @@ package workflow -import "github.com/github/gh-aw/pkg/logger" +import ( + "encoding/json" + + "github.com/github/gh-aw/pkg/logger" +) var frontmatterExtractionSecurityLog = logger.New("workflow:frontmatter_extraction_security") @@ -121,9 +125,16 @@ func (c *Compiler) extractSandboxConfig(frontmatter map[string]any) *SandboxConf config.MCP = c.extractMCPGatewayConfig(mcpVal) } - // If we found agent field, return the new format config - if config.Agent != nil { - frontmatterExtractionSecurityLog.Print("Sandbox configured with new format (agent)") + if enclavesVal, hasEnclaves := sandboxObj["enclaves"]; hasEnclaves { + frontmatterExtractionSecurityLog.Print("Extracting enclave configuration") + config.Enclaves = extractEnclaveConfigs(enclavesVal) + } + + // Agent and MCP already select the new sandbox format. Enclaves alone do not: + // continue parsing legacy type/config so adding enclaves cannot discard existing + // sandbox restrictions. + if config.Agent != nil || config.MCP != nil { + frontmatterExtractionSecurityLog.Print("Sandbox configured with new format") return config } @@ -142,6 +153,28 @@ func (c *Compiler) extractSandboxConfig(frontmatter map[string]any) *SandboxConf return config } +func extractEnclaveConfigs(value any) EnclavesConfig { + items, ok := value.([]any) + if !ok { + return EnclavesConfig{nil} + } + enclaves := make(EnclavesConfig, 0, len(items)) + for _, item := range items { + data, err := json.Marshal(item) + if err != nil { + enclaves = append(enclaves, nil) + continue + } + var enclave EnclaveConfig + if err := json.Unmarshal(data, &enclave); err != nil { + enclaves = append(enclaves, nil) + continue + } + enclaves = append(enclaves, &enclave) + } + return enclaves +} + // extractAgentSandboxConfig extracts agent sandbox configuration func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { // Handle boolean format: agent: false (disables agent sandbox but keeps MCP gateway) diff --git a/pkg/workflow/frontmatter_serialization.go b/pkg/workflow/frontmatter_serialization.go index d70053d00f2..252974c8013 100644 --- a/pkg/workflow/frontmatter_serialization.go +++ b/pkg/workflow/frontmatter_serialization.go @@ -120,10 +120,6 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { // Convert MCPScriptsConfig to map - would need a ToMap method result["mcp-scripts"] = fc.MCPScripts } - if fc.Enclaves != nil { - result["enclaves"] = fc.Enclaves - } - // Event and trigger configuration if fc.On != nil { result["on"] = fc.On diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 817f5902c8b..e0fde496cc1 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -344,7 +344,6 @@ type FrontmatterConfig struct { Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` - Enclaves *EnclavesConfig `json:"enclaves,omitempty"` PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) // Event and trigger configuration diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index defdc9d1934..d792b7679b8 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -39,6 +39,8 @@ type SandboxConfig struct { // New fields Agent *AgentSandboxConfig `yaml:"agent,omitempty"` // Agent sandbox configuration MCP *MCPGatewayRuntimeConfig `yaml:"mcp,omitempty"` // MCP gateway configuration + // Enclaves are AWF-owned private repository executors exposed through mcpg. + Enclaves EnclavesConfig `yaml:"enclaves,omitempty" json:"enclaves,omitempty"` // Legacy fields (for backward compatibility) Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "default" or "sandbox-runtime" diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 699031bbff9..1f59fe36399 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -768,133 +768,211 @@ } }, "enclaves": { - "type": "object", - "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes enabled executors only through an AWF-owned MCP server and the compiler-launched trusted mcpg gateway.", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable the unified enclave subsystem. Cannot be enabled with boundedQueries or boundedAgents." - }, - "privateRepos": { - "type": "array", - "minItems": 1, - "description": "Private repositories shared by every configured enclave executor. Sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", - "items": { + "type": "array", + "description": "Unified private-repository script and agent enclaves. Repositories shared across entries use one per-run information budget, and AWF exposes each entry only through its owned MCP server and the compiler-launched trusted mcpg gateway.", + "minItems": 1, + "maxItems": 2, + "items": { + "oneOf": [ + { "type": "object", "additionalProperties": false, - "required": ["repo", "sensitivity"], + "required": ["type", "repositories"], "properties": { - "repo": { + "type": { + "const": "script" + }, + "repositories": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + "enum": ["docker", "gvisor", "sbx"], + "default": "docker" }, - "sensitivity": { "type": "string", "enum": ["public", "internal", "confidential", "sealed"] } - } - } - }, - "executors": { - "type": "object", - "additionalProperties": false, - "description": "Trusted executor definitions. Images, runtimes, networks, models, timeouts, and resources are AWF configuration and must never be accepted from an invocation request.", - "properties": { - "script": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": false }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned script-executor image." - }, - "network": { "const": "none", "default": "none" }, - "interpreter": { "const": "python3", "default": "python3" }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, - "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpuLimit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pidsLimit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfsLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "maxOutputBytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "maxScriptBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, - "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } - } - }, - "agent": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": false }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned engine image." - }, - "network": { "const": "api-proxy-only", "default": "api-proxy-only" }, - "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, - "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" - }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, - "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpuLimit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pidsLimit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfsLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "maxOutputBytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "maxTaskBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, - "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, - "maxModelRequests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, - "maxModelTokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500 }, - "if": { - "properties": { "enabled": { "const": true } }, - "required": ["enabled"] + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "default": 30 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 }, - "then": { "required": ["model"] } + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxScriptBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 65536 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 + } } - } - } - }, - "if": { - "properties": { "enabled": { "const": true } }, - "required": ["enabled"] - }, - "then": { - "required": ["privateRepos", "executors"], - "properties": { - "executors": { - "anyOf": [ - { - "required": ["script"], - "properties": { - "script": { - "required": ["enabled"], - "properties": { "enabled": { "const": true } } - } - } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "repositories", "model"], + "properties": { + "type": { + "const": "agent" }, - { - "required": ["agent"], - "properties": { - "agent": { - "required": ["enabled"], - "properties": { "enabled": { "const": true } } + "repositories": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } } } + }, + "runtime": { + "type": "string", + "enum": ["docker", "gvisor", "sbx"], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "engine": { + "type": "string", + "enum": ["copilot", "claude", "codex", "gemini"], + "default": "copilot" + }, + "profile": { + "type": "string", + "enum": ["openai", "anthropic"], + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "default": 120 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 4096 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 8 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "default": 1024 } - ] + } } - } + ] } }, "boundedQueries": { diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 6ad1b238315..fd48f51440f 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -72,7 +72,7 @@ func (c *Compiler) buildInitialWorkflowData( NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), RunnerConfig: extractRunnerConfig(result.Frontmatter), - Enclaves: extractEnclavesConfig(toolsResult.parsedFrontmatter), + Enclaves: extractEnclavesConfig(engineSetup.sandboxConfig), NeedsTextOutput: toolsResult.needsTextOutput, ToolsTimeout: toolsResult.toolsTimeout, ToolsStartupTimeout: toolsResult.toolsStartupTimeout, @@ -215,11 +215,11 @@ func (c *Compiler) buildInitialWorkflowData( return workflowData } -func extractEnclavesConfig(frontmatter *FrontmatterConfig) *EnclavesConfig { - if frontmatter == nil { +func extractEnclavesConfig(sandbox *SandboxConfig) EnclavesConfig { + if sandbox == nil { return nil } - return frontmatter.Enclaves + return sandbox.Enclaves } func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) map[string]LSPServerConfig { diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index d49aee4d479..d52980d1c25 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -135,7 +135,7 @@ type WorkflowData struct { SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes SafeOutputsInputEnvVars map[string]string // GH_AW_INPUT_* env vars referenced by safe-outputs config; populated during MCP setup generation so renderers can forward them to the nested container MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools - Enclaves *EnclavesConfig // AWF-owned private repository enclave executors + Enclaves EnclavesConfig // AWF-owned private repository enclave executors LabelNames []string // label names that must match for pull_request_target labeled events (on.labels) Roles []string // permission levels required to trigger workflow Bots []string // allow list of bot identifiers that can trigger workflow From 1173fd8da89acada06f085e05efea6f3b51f7276 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:50 +0000 Subject: [PATCH 4/8] Fix lint-errors: add Example: guidance to error messages in enclaves.go, compiler_validators.go, mcp_renderer.go Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/workflow/compiler_validators.go | 6 +++--- pkg/workflow/enclaves.go | 22 +++++++++++----------- pkg/workflow/mcp_renderer.go | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index da58ca25c77..6247fa23f9f 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -495,13 +495,13 @@ func validateOTLPWorkloadIdentity(workflowData *WorkflowData) error { return nil } if !strings.EqualFold(strings.TrimSpace(workloadIdentity.Provider), "google") { - return errors.New("observability.otlp.workload-identity.provider must be google") + return errors.New("observability.otlp.workload-identity.provider must be google. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } if strings.TrimSpace(workloadIdentity.Audience) == "" { - return errors.New("observability.otlp.workload-identity.audience is required") + return errors.New("observability.otlp.workload-identity.audience is required. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } if getOTLPGitHubAppTokenConfig(workflowData.RawFrontmatter) != nil { - return errors.New("observability.otlp.workload-identity cannot be combined with GitHub App credentials") + return errors.New("observability.otlp.workload-identity cannot be combined with GitHub App credentials; use one authentication method only. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } return nil } diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index 2375a2c78e8..9521937826f 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -105,48 +105,48 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.BoundedQueries != nil { - return errors.New("sandbox.enclaves cannot be combined with tools.github.bounded-queries") + return errors.New("sandbox.enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential") } seenTypes := make(map[string]struct{}, len(workflowData.Enclaves)) repositorySensitivities := make(map[string]string) for i, enclave := range workflowData.Enclaves { if enclave == nil { - return fmt.Errorf("sandbox.enclaves[%d] must be an object", i) + return fmt.Errorf("sandbox.enclaves[%d] must be an object. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) } if enclave.Type != "script" && enclave.Type != "agent" { - return fmt.Errorf("sandbox.enclaves[%d].type must be script or agent", i) + return fmt.Errorf("sandbox.enclaves[%d].type must be script or agent. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) } if _, ok := seenTypes[enclave.Type]; ok { - return fmt.Errorf("sandbox.enclaves contains duplicate executor type %q", enclave.Type) + return fmt.Errorf("sandbox.enclaves contains duplicate executor type %q; each type may appear at most once. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", enclave.Type) } seenTypes[enclave.Type] = struct{}{} if enclave.Type == "agent" && enclave.Model == "" { - return fmt.Errorf("sandbox.enclaves[%d].model is required for agent enclaves", i) + return fmt.Errorf("sandbox.enclaves[%d].model is required for agent enclaves. Example:\n\nsandbox:\n enclaves:\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) } if len(enclave.Repositories) == 0 { - return fmt.Errorf("sandbox.enclaves[%d].repositories must contain at least one repository", i) + return fmt.Errorf("sandbox.enclaves[%d].repositories must contain at least one repository. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) } seenInEnclave := make(map[string]struct{}, len(enclave.Repositories)) for j, repo := range enclave.Repositories { if repo == nil { - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d] must be an object", i, j) + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d] must be an object. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, j) } parts := strings.SplitN(repo.Repo, "/", 2) if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].repo must be a bare owner/repository slug", i, j) + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].repo must be a bare owner/repository slug (e.g. org/my-repo). Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/my-repo\n sensitivity: confidential", i, j) } key := strings.ToLower(repo.Repo) if _, ok := seenInEnclave[key]; ok { - return fmt.Errorf("sandbox.enclaves[%d].repositories contains duplicate repository %q", i, repo.Repo) + return fmt.Errorf("sandbox.enclaves[%d].repositories contains duplicate repository %q; each repository must appear at most once per enclave. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, repo.Repo) } seenInEnclave[key] = struct{}{} switch repo.Sensitivity { case "public", "internal", "confidential", "sealed": default: - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].sensitivity must be public, internal, confidential, or sealed", i, j) + return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].sensitivity must be public, internal, confidential, or sealed. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, j) } if sensitivity, ok := repositorySensitivities[key]; ok && sensitivity != repo.Sensitivity { - return fmt.Errorf("repository %q must use the same sensitivity across enclave types", repo.Repo) + return fmt.Errorf("repository %q must use the same sensitivity across enclave types. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", repo.Repo) } repositorySensitivities[key] = repo.Sensitivity } diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index b0128d603d5..46bc0d0c922 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -248,7 +248,7 @@ func RenderJSONMCPConfig( // The config is rendered inside an unquoted bash heredoc; unvalidated IDs // containing shell metacharacters (e.g. $(cmd), `cmd`) would be expanded. if !isSafeMCPServerID(serverID) { - return fmt.Errorf("private-to-public-flows: server ID %q contains characters that are unsafe for shell heredoc emission; IDs must match [A-Za-z0-9_-]+", serverID) + return fmt.Errorf("private-to-public-flows: server ID %q contains characters that are unsafe for shell heredoc emission; IDs must match [A-Za-z0-9_-]+. Example:\n\nfirewall:\n private-to-public-flows:\n allowed-server-ids:\n - my-safe-server", serverID) } fmt.Fprintf(&configBuilder, "%q", serverID) } From f554dca99e263e6a155bab3a3fed3101229c964b Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 10 Aug 2026 18:31:19 -0700 Subject: [PATCH 5/8] Refine unified enclave configuration Move enclaves back to top-level frontmatter and represent executor types with keyed script and agent entries using repos. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 245af6c2-3f8d-47e0-b99a-e0144e107a0d --- docs/src/content/docs/reference/enclaves.md | 24 +-- pkg/parser/schema_test.go | 41 ++++ pkg/parser/schemas/main_workflow_schema.json | 177 +++++++++--------- pkg/workflow/enclaves.go | 170 +++++++++++------ pkg/workflow/enclaves_test.go | 83 ++++---- .../frontmatter_extraction_security.go | 37 +--- pkg/workflow/frontmatter_serialization.go | 4 + pkg/workflow/frontmatter_types.go | 1 + pkg/workflow/sandbox.go | 2 - pkg/workflow/schemas/awf-config.schema.json | 103 +++++----- pkg/workflow/workflow_builder.go | 17 +- 11 files changed, 367 insertions(+), 292 deletions(-) diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md index 2da06737698..79759056dad 100644 --- a/docs/src/content/docs/reference/enclaves.md +++ b/docs/src/content/docs/reference/enclaves.md @@ -3,7 +3,7 @@ title: Private repository enclaves description: Configure unified AWF script and agent enclaves through the trusted MCP gateway. --- -The `sandbox.enclaves` array enables finite-disclosure access to approved private repositories. The compiler registers `enclave_run_script` or `enclave_run_agent` from the entry types present on the `awf-enclave` MCP route. Omit the array to disable enclaves. +The top-level `enclaves` array enables finite-disclosure access to approved private repositories. The compiler registers `enclave_run_script` or `enclave_run_agent` from the keyed entries present on the `awf-enclave` MCP route. Omit the array to disable enclaves. Enclaves require AWF network isolation. Configure `sandbox.agent.sudo: false` (or the `docker-sbx` runtime) so the compiler launches mcpg in bridge mode and AWF can attach it to the isolated topology. @@ -12,18 +12,18 @@ sandbox: agent: id: awf sudo: false - enclaves: - - type: script - repositories: - - repo: octo-org/private-service - sensitivity: confidential - timeout: 45 - - type: agent - repositories: - - repo: octo-org/private-service - sensitivity: confidential +enclaves: + - script: + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 + - agent: model: gpt-5 - timeout: 180 + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 180 ``` Each type can appear at most once. When the same repository appears in both entries, its sensitivity must match because its information budget is shared across executor types. AWF fixes the script enclave network and interpreter and the agent enclave network internally; workflows cannot override those security invariants. diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 28933618a1e..d8ddd93b048 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -9,6 +9,47 @@ import ( "testing" ) +func TestValidateMainWorkflowFrontmatterEnclaves(t *testing.T) { + valid := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "enclaves": []any{ + map[string]any{ + "script": nil, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 45, + }, + map[string]any{ + "agent": map[string]any{"model": "gpt-5"}, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(valid, "workflow.md"); err != nil { + t.Fatalf("expected keyed top-level enclaves to validate: %v", err) + } + + legacy := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "sandbox": map[string]any{ + "enclaves": []any{ + map[string]any{ + "type": "script", + "repositories": []any{}, + }, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(legacy, "workflow.md"); err == nil { + t.Fatal("expected legacy sandbox.enclaves shape to be rejected") + } +} + func TestValidateWithSchema(t *testing.T) { tests := []struct { name string diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0baeebe103a..8b9ef0c93da 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3415,6 +3415,70 @@ } ] }, + "enclaves": { + "type": "array", + "description": "AWF-owned private-repository executors exposed only through the compiler-launched MCP gateway. Omit this field to disable enclaves.", + "minItems": 1, + "maxItems": 2, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["script", "repos"], + "properties": { + "script": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 } + } + }, + "repos": { "$ref": "#/$defs/enclave-repos" }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["agent", "repos"], + "properties": { + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["model"], + "properties": { + "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, + "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, + "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, + "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, + "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, + "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + } + }, + "repos": { "$ref": "#/$defs/enclave-repos" }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 } + } + } + ] + } + }, "sandbox": { "description": "Sandbox configuration for AI engines. Controls agent sandbox (AWF) and MCP gateway. The MCP gateway is always enabled and cannot be disabled.", "oneOf": [ @@ -3425,99 +3489,8 @@ }, { "type": "object", - "description": "Object format for full sandbox configuration with agent, enclave, and MCP gateway options", + "description": "Object format for full sandbox configuration with agent and MCP gateway options", "properties": { - "enclaves": { - "type": "array", - "description": "AWF-owned private-repository executors exposed only through the compiler-launched MCP gateway. Omit this field to disable enclaves.", - "minItems": 1, - "maxItems": 2, - "items": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["type", "repositories"], - "properties": { - "type": { "const": "script" }, - "repositories": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["repo", "sensitivity"], - "properties": { - "repo": { - "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "confidential", "sealed"] - } - } - } - }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { "type": "string", "minLength": 1, "maxLength": 500 }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, - "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 }, - "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["type", "repositories", "model"], - "properties": { - "type": { "const": "agent" }, - "repositories": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["repo", "sensitivity"], - "properties": { - "repo": { - "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "confidential", "sealed"] - } - } - } - }, - "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, - "image": { "type": "string", "minLength": 1, "maxLength": 500 }, - "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, - "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, - "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, - "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, - "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, - "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, - "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, - "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, - "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, - "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, - "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 }, - "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, - "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } - } - } - ] - } - }, "type": { "type": "string", "enum": ["default", "awf"], @@ -12420,6 +12393,26 @@ } ], "$defs": { + "enclave-repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, "github_actions_runs_on": { "description": "Runner type for workflow execution (GitHub Actions standard field). Supports multiple forms: simple string for single runner label (e.g., 'ubuntu-latest'), array for runner selection with fallbacks, or object for GitHub-hosted runner groups with specific labels. For agentic workflows, runner selection matters when AI workloads require specific compute resources or when using self-hosted runners with specialized capabilities. Typically configured at the job level instead. See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job", "oneOf": [ diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index 9521937826f..cb6dfc93132 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "errors" "fmt" "regexp" @@ -35,24 +36,49 @@ type EnclaveRepository struct { } type EnclaveConfig struct { - Type string `json:"type"` - Repositories []*EnclaveRepository `json:"repositories"` - Runtime string `json:"runtime,omitempty"` - Image string `json:"image,omitempty"` - Timeout int `json:"timeout,omitempty"` - MemoryLimit string `json:"memory-limit,omitempty"` - CPULimit string `json:"cpu-limit,omitempty"` - PIDsLimit int `json:"pids-limit,omitempty"` - TmpfsLimit string `json:"tmpfs-limit,omitempty"` - MaxOutputBytes int `json:"max-output-bytes,omitempty"` - MaxScriptBytes int `json:"max-script-bytes,omitempty"` - MaxInvocations int `json:"max-invocations,omitempty"` - Engine string `json:"engine,omitempty"` - Profile string `json:"profile,omitempty"` - Model string `json:"model,omitempty"` - MaxTaskBytes int `json:"max-task-bytes,omitempty"` - MaxModelRequests int `json:"max-model-requests,omitempty"` - MaxModelTokens int `json:"max-model-tokens,omitempty"` + Script *ScriptEnclaveConfig `json:"script,omitempty"` + Agent *AgentEnclaveConfig `json:"agent,omitempty"` + Repos []*EnclaveRepository `json:"repos"` + Runtime string `json:"runtime,omitempty"` + Image string `json:"image,omitempty"` + Timeout int `json:"timeout,omitempty"` + MemoryLimit string `json:"memory-limit,omitempty"` + CPULimit string `json:"cpu-limit,omitempty"` + PIDsLimit int `json:"pids-limit,omitempty"` + TmpfsLimit string `json:"tmpfs-limit,omitempty"` + MaxOutputBytes int `json:"max-output-bytes,omitempty"` + MaxInvocations int `json:"max-invocations,omitempty"` +} + +type ScriptEnclaveConfig struct { + MaxScriptBytes int `json:"max-script-bytes,omitempty"` +} + +type AgentEnclaveConfig struct { + Engine string `json:"engine,omitempty"` + Profile string `json:"profile,omitempty"` + Model string `json:"model,omitempty"` + MaxTaskBytes int `json:"max-task-bytes,omitempty"` + MaxModelRequests int `json:"max-model-requests,omitempty"` + MaxModelTokens int `json:"max-model-tokens,omitempty"` +} + +// UnmarshalJSON preserves the explicit null marker produced by YAML `script:`. +func (e *EnclaveConfig) UnmarshalJSON(data []byte) error { + type enclaveAlias EnclaveConfig + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded enclaveAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *e = EnclaveConfig(decoded) + if script, ok := raw["script"]; ok && string(script) == "null" { + e.Script = &ScriptEnclaveConfig{} + } + return nil } func enclavesEnabled(workflowData *WorkflowData) bool { @@ -65,10 +91,10 @@ func enabledEnclaveTools(workflowData *WorkflowData) []string { if enclave == nil { continue } - switch enclave.Type { - case "script": + if enclave.Script != nil { tools = append(tools, "enclave_run_script") - case "agent": + } + if enclave.Agent != nil { tools = append(tools, "enclave_run_agent") } } @@ -81,13 +107,20 @@ func enclaveToolTimeout(workflowData *WorkflowData) int { if enclave == nil { continue } - timeout := enclave.Timeout - if timeout == 0 && enclave.Type == "script" { - timeout = defaultScriptEnclaveTimeout - } else if timeout == 0 && enclave.Type == "agent" { - timeout = defaultAgentEnclaveTimeout + if enclave.Script != nil { + timeout := enclave.Timeout + if timeout == 0 { + timeout = defaultScriptEnclaveTimeout + } + maxTimeout = max(maxTimeout, timeout) + } + if enclave.Agent != nil { + timeout := enclave.Timeout + if timeout == 0 { + timeout = defaultAgentEnclaveTimeout + } + maxTimeout = max(maxTimeout, timeout) } - maxTimeout = max(maxTimeout, timeout) } if maxTimeout == 0 { return 0 @@ -100,53 +133,54 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { return nil } if !isAWFNetworkIsolationEnabled(workflowData) { - return errors.New("sandbox.enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") + return errors.New("enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") } if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.BoundedQueries != nil { - return errors.New("sandbox.enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential") + return errors.New("enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves") } seenTypes := make(map[string]struct{}, len(workflowData.Enclaves)) repositorySensitivities := make(map[string]string) for i, enclave := range workflowData.Enclaves { if enclave == nil { - return fmt.Errorf("sandbox.enclaves[%d] must be an object. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) + return fmt.Errorf("enclaves[%d] must be an object", i) } - if enclave.Type != "script" && enclave.Type != "agent" { - return fmt.Errorf("sandbox.enclaves[%d].type must be script or agent. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) + enclaveType, ok := enclaveExecutor(enclave) + if !ok { + return fmt.Errorf("enclaves[%d] must contain exactly one of script or agent", i) } - if _, ok := seenTypes[enclave.Type]; ok { - return fmt.Errorf("sandbox.enclaves contains duplicate executor type %q; each type may appear at most once. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", enclave.Type) + if _, ok := seenTypes[enclaveType]; ok { + return fmt.Errorf("enclaves contains duplicate executor type %q; each type may appear at most once", enclaveType) } - seenTypes[enclave.Type] = struct{}{} - if enclave.Type == "agent" && enclave.Model == "" { - return fmt.Errorf("sandbox.enclaves[%d].model is required for agent enclaves. Example:\n\nsandbox:\n enclaves:\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) + seenTypes[enclaveType] = struct{}{} + if enclaveType == "agent" && enclave.Agent.Model == "" { + return fmt.Errorf("enclaves[%d].agent.model is required", i) } - if len(enclave.Repositories) == 0 { - return fmt.Errorf("sandbox.enclaves[%d].repositories must contain at least one repository. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i) + if len(enclave.Repos) == 0 { + return fmt.Errorf("enclaves[%d].repos must contain at least one repository", i) } - seenInEnclave := make(map[string]struct{}, len(enclave.Repositories)) - for j, repo := range enclave.Repositories { + seenInEnclave := make(map[string]struct{}, len(enclave.Repos)) + for j, repo := range enclave.Repos { if repo == nil { - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d] must be an object. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, j) + return fmt.Errorf("enclaves[%d].repos[%d] must be an object", i, j) } parts := strings.SplitN(repo.Repo, "/", 2) if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].repo must be a bare owner/repository slug (e.g. org/my-repo). Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/my-repo\n sensitivity: confidential", i, j) + return fmt.Errorf("enclaves[%d].repos[%d].repo must be a bare owner/repository slug (e.g. org/my-repo)", i, j) } key := strings.ToLower(repo.Repo) if _, ok := seenInEnclave[key]; ok { - return fmt.Errorf("sandbox.enclaves[%d].repositories contains duplicate repository %q; each repository must appear at most once per enclave. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, repo.Repo) + return fmt.Errorf("enclaves[%d].repos contains duplicate repository %q", i, repo.Repo) } seenInEnclave[key] = struct{}{} switch repo.Sensitivity { case "public", "internal", "confidential", "sealed": default: - return fmt.Errorf("sandbox.enclaves[%d].repositories[%d].sensitivity must be public, internal, confidential, or sealed. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential", i, j) + return fmt.Errorf("enclaves[%d].repos[%d].sensitivity must be public, internal, confidential, or sealed", i, j) } if sensitivity, ok := repositorySensitivities[key]; ok && sensitivity != repo.Sensitivity { - return fmt.Errorf("repository %q must use the same sensitivity across enclave types. Example:\n\nsandbox:\n enclaves:\n - type: script\n repositories:\n - repo: org/repo\n sensitivity: confidential\n - type: agent\n model: gpt-5\n repositories:\n - repo: org/repo\n sensitivity: confidential", repo.Repo) + return fmt.Errorf("repository %q must use the same sensitivity across enclave types", repo.Repo) } repositorySensitivities[key] = repo.Sensitivity } @@ -154,18 +188,32 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { return nil } +func enclaveExecutor(enclave *EnclaveConfig) (string, bool) { + if enclave.Script != nil && enclave.Agent == nil { + return "script", true + } + if enclave.Agent != nil && enclave.Script == nil { + return "agent", true + } + return "", false +} + func buildAWFEnclavesConfig(config EnclavesConfig) []map[string]any { if len(config) == 0 { return nil } result := make([]map[string]any, 0, len(config)) for _, enclave := range config { - values := map[string]any{"type": enclave.Type} - repositories := make([]map[string]any, 0, len(enclave.Repositories)) - for _, repo := range enclave.Repositories { - repositories = append(repositories, map[string]any{"repo": repo.Repo, "sensitivity": repo.Sensitivity}) + enclaveType, ok := enclaveExecutor(enclave) + if !ok { + continue + } + values := make(map[string]any) + repos := make([]map[string]any, 0, len(enclave.Repos)) + for _, repo := range enclave.Repos { + repos = append(repos, map[string]any{"repo": repo.Repo, "sensitivity": repo.Sensitivity}) } - values["repositories"] = repositories + values["repos"] = repos addEnclaveString(values, "runtime", enclave.Runtime) addEnclaveString(values, "image", enclave.Image) addEnclaveInt(values, "timeout", enclave.Timeout) @@ -175,15 +223,19 @@ func buildAWFEnclavesConfig(config EnclavesConfig) []map[string]any { addEnclaveString(values, "tmpfsLimit", enclave.TmpfsLimit) addEnclaveInt(values, "maxOutputBytes", enclave.MaxOutputBytes) addEnclaveInt(values, "maxInvocations", enclave.MaxInvocations) - if enclave.Type == "script" { - addEnclaveInt(values, "maxScriptBytes", enclave.MaxScriptBytes) + if enclaveType == "script" { + script := make(map[string]any) + addEnclaveInt(script, "maxScriptBytes", enclave.Script.MaxScriptBytes) + values["script"] = script } else { - addEnclaveString(values, "engine", enclave.Engine) - addEnclaveString(values, "profile", enclave.Profile) - addEnclaveString(values, "model", enclave.Model) - addEnclaveInt(values, "maxTaskBytes", enclave.MaxTaskBytes) - addEnclaveInt(values, "maxModelRequests", enclave.MaxModelRequests) - addEnclaveInt(values, "maxModelTokens", enclave.MaxModelTokens) + agent := make(map[string]any) + addEnclaveString(agent, "engine", enclave.Agent.Engine) + addEnclaveString(agent, "profile", enclave.Agent.Profile) + addEnclaveString(agent, "model", enclave.Agent.Model) + addEnclaveInt(agent, "maxTaskBytes", enclave.Agent.MaxTaskBytes) + addEnclaveInt(agent, "maxModelRequests", enclave.Agent.MaxModelRequests) + addEnclaveInt(agent, "maxModelTokens", enclave.Agent.MaxModelTokens) + values["agent"] = agent } result = append(result, values) } diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go index 2af9439cab8..6d29e938fe4 100644 --- a/pkg/workflow/enclaves_test.go +++ b/pkg/workflow/enclaves_test.go @@ -27,18 +27,18 @@ func enclaveWorkflowData(script, agent bool, scriptTimeout, agentTimeout int) *W } if script { data.Enclaves = append(data.Enclaves, &EnclaveConfig{ - Type: "script", Timeout: scriptTimeout, Repositories: enclaveTestRepositories(), + Script: &ScriptEnclaveConfig{}, Timeout: scriptTimeout, Repos: enclaveTestRepos(), }) } if agent { data.Enclaves = append(data.Enclaves, &EnclaveConfig{ - Type: "agent", Model: "gpt-5", Timeout: agentTimeout, Repositories: enclaveTestRepositories(), + Agent: &AgentEnclaveConfig{Model: "gpt-5"}, Timeout: agentTimeout, Repos: enclaveTestRepos(), }) } return data } -func enclaveTestRepositories() []*EnclaveRepository { +func enclaveTestRepos() []*EnclaveRepository { return []*EnclaveRepository{{ Repo: "octo-org/private-service", Sensitivity: "confidential", }} @@ -93,7 +93,7 @@ func TestValidateEnclavesRejectsBoundedQueries(t *testing.T) { func TestValidateEnclavesRejectsDuplicateTypes(t *testing.T) { data := enclaveWorkflowData(true, false, 30, 0) data.Enclaves = append(data.Enclaves, &EnclaveConfig{ - Type: "script", Repositories: enclaveTestRepositories(), + Script: &ScriptEnclaveConfig{}, Repos: enclaveTestRepos(), }) err := validateEnclavesConfig(data) require.Error(t, err) @@ -102,7 +102,7 @@ func TestValidateEnclavesRejectsDuplicateTypes(t *testing.T) { func TestValidateEnclavesRequiresConsistentRepositorySensitivity(t *testing.T) { data := enclaveWorkflowData(true, true, 30, 120) - data.Enclaves[1].Repositories[0].Sensitivity = "sealed" + data.Enclaves[1].Repos[0].Sensitivity = "sealed" err := validateEnclavesConfig(data) require.Error(t, err) assert.Contains(t, err.Error(), "must use the same sensitivity across enclave types") @@ -110,42 +110,44 @@ func TestValidateEnclavesRequiresConsistentRepositorySensitivity(t *testing.T) { func TestValidateEnclavesRequiresAgentModelOnly(t *testing.T) { data := enclaveWorkflowData(false, true, 0, 120) - data.Enclaves[0].Model = "" + data.Enclaves[0].Agent.Model = "" err := validateEnclavesConfig(data) require.Error(t, err) - assert.Contains(t, err.Error(), "model is required for agent enclaves") + assert.Contains(t, err.Error(), "agent.model is required") script := enclaveWorkflowData(true, false, 30, 0) assert.NoError(t, validateEnclavesConfig(script)) } -func TestExtractEnclavesPreservesLegacySandboxConfig(t *testing.T) { - compiler := NewCompiler() - config := compiler.extractSandboxConfig(map[string]any{ - "sandbox": map[string]any{ - "type": "awf", - "config": map[string]any{ - "filesystem": map[string]any{ - "denyRead": []any{"/private"}, - }, - }, - "enclaves": []any{ - map[string]any{ - "type": "script", - "repositories": []any{ - map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, - }, +func TestParseTopLevelKeyedEnclaves(t *testing.T) { + config, err := ParseFrontmatterConfig(map[string]any{ + "enclaves": []any{ + map[string]any{ + "script": nil, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, }, + "timeout": 45, }, }, }) - require.NotNil(t, config) - assert.Equal(t, SandboxTypeAWF, config.Type) - require.NotNil(t, config.Config) - require.NotNil(t, config.Config.Filesystem) - assert.Equal(t, []string{"/private"}, config.Config.Filesystem.DenyRead) + require.NoError(t, err) require.Len(t, config.Enclaves, 1) - assert.Equal(t, "script", config.Enclaves[0].Type) + require.NotNil(t, config.Enclaves[0].Script) + assert.Equal(t, 45, config.Enclaves[0].Timeout) + require.Len(t, config.Enclaves[0].Repos, 1) +} + +func TestEnclaveConfigRejectsAmbiguousDiscriminator(t *testing.T) { + data := enclaveWorkflowData(false, false, 0, 0) + data.Enclaves = EnclavesConfig{{ + Script: &ScriptEnclaveConfig{}, + Agent: &AgentEnclaveConfig{Model: "gpt-5"}, + Repos: enclaveTestRepos(), + }} + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of script or agent") } func TestBuildAWFConfigJSONEnclaves(t *testing.T) { @@ -160,11 +162,12 @@ func TestBuildAWFConfigJSONEnclaves(t *testing.T) { enclaves := config["enclaves"].([]any) script := enclaves[0].(map[string]any) agent := enclaves[1].(map[string]any) - assert.Equal(t, "script", script["type"]) + scriptConfig := script["script"].(map[string]any) + agentConfig := agent["agent"].(map[string]any) + assert.Empty(t, scriptConfig) assert.InDelta(t, 45, script["timeout"], 0) - assert.Equal(t, "agent", agent["type"]) - assert.Equal(t, "gpt-5", agent["model"]) - assert.Contains(t, script, "repositories") + assert.Equal(t, "gpt-5", agentConfig["model"]) + assert.Contains(t, script, "repos") assert.NotContains(t, script, "enabled") assert.NotContains(t, script, "network") assert.NotContains(t, script, "interpreter") @@ -215,12 +218,12 @@ sandbox: id: awf sudo: false version: latest - enclaves: - - type: script - repositories: - - repo: octo-org/private-service - sensitivity: confidential - timeout: 45 +enclaves: + - script: + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 --- Use the enclave script executor. @@ -239,7 +242,7 @@ Use the enclave script executor. require.Greater(t, awf, -1) assert.Less(t, gateway, awf) assert.Contains(t, lock, `"awf-enclave"`) - assert.Contains(t, lock, `\"enclaves\":[{\"repositories\":[{\"repo\":\"octo-org/private-service\",\"sensitivity\":\"confidential\"}],\"timeout\":45,\"type\":\"script\"}]`) + assert.Contains(t, lock, `\"enclaves\":[{\"repos\":[{\"repo\":\"octo-org/private-service\",\"sensitivity\":\"confidential\"}],\"script\":{},\"timeout\":45}]`) assert.NotContains(t, lock, "Start Enclave MCP") assert.NotContains(t, lock, "start_enclave") } diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index fa3c994e584..e8dde224918 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -1,10 +1,6 @@ package workflow -import ( - "encoding/json" - - "github.com/github/gh-aw/pkg/logger" -) +import "github.com/github/gh-aw/pkg/logger" var frontmatterExtractionSecurityLog = logger.New("workflow:frontmatter_extraction_security") @@ -125,14 +121,7 @@ func (c *Compiler) extractSandboxConfig(frontmatter map[string]any) *SandboxConf config.MCP = c.extractMCPGatewayConfig(mcpVal) } - if enclavesVal, hasEnclaves := sandboxObj["enclaves"]; hasEnclaves { - frontmatterExtractionSecurityLog.Print("Extracting enclave configuration") - config.Enclaves = extractEnclaveConfigs(enclavesVal) - } - - // Agent and MCP already select the new sandbox format. Enclaves alone do not: - // continue parsing legacy type/config so adding enclaves cannot discard existing - // sandbox restrictions. + // Agent and MCP select the new sandbox format. if config.Agent != nil || config.MCP != nil { frontmatterExtractionSecurityLog.Print("Sandbox configured with new format") return config @@ -153,28 +142,6 @@ func (c *Compiler) extractSandboxConfig(frontmatter map[string]any) *SandboxConf return config } -func extractEnclaveConfigs(value any) EnclavesConfig { - items, ok := value.([]any) - if !ok { - return EnclavesConfig{nil} - } - enclaves := make(EnclavesConfig, 0, len(items)) - for _, item := range items { - data, err := json.Marshal(item) - if err != nil { - enclaves = append(enclaves, nil) - continue - } - var enclave EnclaveConfig - if err := json.Unmarshal(data, &enclave); err != nil { - enclaves = append(enclaves, nil) - continue - } - enclaves = append(enclaves, &enclave) - } - return enclaves -} - // extractAgentSandboxConfig extracts agent sandbox configuration func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { // Handle boolean format: agent: false (disables agent sandbox but keeps MCP gateway) diff --git a/pkg/workflow/frontmatter_serialization.go b/pkg/workflow/frontmatter_serialization.go index 252974c8013..44ab72ab1f0 100644 --- a/pkg/workflow/frontmatter_serialization.go +++ b/pkg/workflow/frontmatter_serialization.go @@ -120,6 +120,10 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { // Convert MCPScriptsConfig to map - would need a ToMap method result["mcp-scripts"] = fc.MCPScripts } + if len(fc.Enclaves) > 0 { + result["enclaves"] = fc.Enclaves + } + // Event and trigger configuration if fc.On != nil { result["on"] = fc.On diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index e0fde496cc1..aa367c6f477 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -344,6 +344,7 @@ type FrontmatterConfig struct { Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` + Enclaves EnclavesConfig `json:"enclaves,omitempty"` PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) // Event and trigger configuration diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index d792b7679b8..defdc9d1934 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -39,8 +39,6 @@ type SandboxConfig struct { // New fields Agent *AgentSandboxConfig `yaml:"agent,omitempty"` // Agent sandbox configuration MCP *MCPGatewayRuntimeConfig `yaml:"mcp,omitempty"` // MCP gateway configuration - // Enclaves are AWF-owned private repository executors exposed through mcpg. - Enclaves EnclavesConfig `yaml:"enclaves,omitempty" json:"enclaves,omitempty"` // Legacy fields (for backward compatibility) Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "default" or "sandbox-runtime" diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 1f59fe36399..f2847eeb80b 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -777,12 +777,21 @@ { "type": "object", "additionalProperties": false, - "required": ["type", "repositories"], + "required": ["script", "repos"], "properties": { - "type": { - "const": "script" + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxScriptBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 65536 + } + } }, - "repositories": { + "repos": { "type": "array", "minItems": 1, "items": { @@ -845,12 +854,6 @@ "maximum": 8192, "default": 8192 }, - "maxScriptBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 65536 - }, "maxInvocations": { "type": "integer", "minimum": 1, @@ -862,12 +865,50 @@ { "type": "object", "additionalProperties": false, - "required": ["type", "repositories", "model"], + "required": ["agent", "repos"], "properties": { - "type": { - "const": "agent" + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["model"], + "properties": { + "engine": { + "type": "string", + "enum": ["copilot", "claude", "codex", "gemini"], + "default": "copilot" + }, + "profile": { + "type": "string", + "enum": ["openai", "anthropic"], + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 4096 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "default": 1024 + } + } }, - "repositories": { + "repos": { "type": "array", "minItems": 1, "items": { @@ -897,22 +938,6 @@ "minLength": 1, "maxLength": 500 }, - "engine": { - "type": "string", - "enum": ["copilot", "claude", "codex", "gemini"], - "default": "copilot" - }, - "profile": { - "type": "string", - "enum": ["openai", "anthropic"], - "default": "openai" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" - }, "timeout": { "type": "integer", "minimum": 1, @@ -946,29 +971,11 @@ "maximum": 8192, "default": 8192 }, - "maxTaskBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 4096 - }, "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 - }, - "maxModelRequests": { - "type": "integer", - "minimum": 1, - "maximum": 64, - "default": 8 - }, - "maxModelTokens": { - "type": "integer", - "minimum": 1, - "maximum": 32768, - "default": 1024 } } } diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index fd48f51440f..a8548365119 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -72,7 +72,7 @@ func (c *Compiler) buildInitialWorkflowData( NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), RunnerConfig: extractRunnerConfig(result.Frontmatter), - Enclaves: extractEnclavesConfig(engineSetup.sandboxConfig), + Enclaves: extractEnclavesConfig(result.Frontmatter), NeedsTextOutput: toolsResult.needsTextOutput, ToolsTimeout: toolsResult.toolsTimeout, ToolsStartupTimeout: toolsResult.toolsStartupTimeout, @@ -215,11 +215,20 @@ func (c *Compiler) buildInitialWorkflowData( return workflowData } -func extractEnclavesConfig(sandbox *SandboxConfig) EnclavesConfig { - if sandbox == nil { +func extractEnclavesConfig(frontmatter map[string]any) EnclavesConfig { + raw, ok := frontmatter["enclaves"] + if !ok { return nil } - return sandbox.Enclaves + data, err := json.Marshal(raw) + if err != nil { + return EnclavesConfig{nil} + } + var enclaves EnclavesConfig + if err := json.Unmarshal(data, &enclaves); err != nil { + return EnclavesConfig{nil} + } + return enclaves } func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) map[string]LSPServerConfig { From d5700b734050eb83ef55149e60e78613bf02f7ab Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 10 Aug 2026 19:24:16 -0700 Subject: [PATCH 6/8] Cover enclave disclosure timing bound Floor mcpg enclave tool timeouts at 630 seconds so AWF can complete its maximum finite-disclosure timing bucket and cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 245af6c2-3f8d-47e0-b99a-e0144e107a0d --- docs/src/content/docs/reference/enclaves.md | 2 +- pkg/workflow/enclaves.go | 4 +++- pkg/workflow/enclaves_test.go | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md index 79759056dad..124104616d8 100644 --- a/docs/src/content/docs/reference/enclaves.md +++ b/docs/src/content/docs/reference/enclaves.md @@ -28,6 +28,6 @@ enclaves: Each type can appear at most once. When the same repository appears in both entries, its sensitivity must match because its information budget is shared across executor types. AWF fixes the script enclave network and interpreter and the agent enclave network internally; workflows cannot override those security invariants. -The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available and sets its tool timeout to the longest configured or default executor timeout plus 30 seconds. +The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available. Its tool timeout is at least 630 seconds, covering AWF's maximum 600-second finite-disclosure timing bucket plus a 30-second cleanup and transport margin. If a future executor timeout exceeds that envelope, the gateway uses the executor timeout plus the same margin. This compiler contract depends on the unified enclave implementation from `github/gh-aw-firewall#6992`. Until that change is available in an AWF release, pinning an older AWF version will not provide the enclave server. diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index cb6dfc93132..4aac5a0729e 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -22,6 +22,8 @@ const ( enclaveMCPReadinessTimeoutMS = 120000 defaultScriptEnclaveTimeout = 30 defaultAgentEnclaveTimeout = 120 + maxEnclaveTimingBucketSeconds = 600 + enclaveMCPTransportAllowance = 30 ) var enclaveRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) @@ -125,7 +127,7 @@ func enclaveToolTimeout(workflowData *WorkflowData) int { if maxTimeout == 0 { return 0 } - return maxTimeout + 30 + return max(maxTimeout+enclaveMCPTransportAllowance, maxEnclaveTimingBucketSeconds+enclaveMCPTransportAllowance) } func validateEnclavesConfig(workflowData *WorkflowData) error { diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go index 6d29e938fe4..a5d1ada75c5 100644 --- a/pkg/workflow/enclaves_test.go +++ b/pkg/workflow/enclaves_test.go @@ -52,9 +52,10 @@ func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { wantTools []string wantTimeout int }{ - {"script only defaults", true, false, 0, 0, []string{"enclave_run_script"}, 60}, - {"agent only defaults", false, true, 0, 0, []string{"enclave_run_agent"}, 150}, - {"both use maximum", true, true, 200, 90, []string{"enclave_run_script", "enclave_run_agent"}, 230}, + {"script only defaults cover timing bucket", true, false, 0, 0, []string{"enclave_run_script"}, 630}, + {"agent only defaults cover timing bucket", false, true, 0, 0, []string{"enclave_run_agent"}, 630}, + {"custom timeouts cover timing bucket", true, true, 200, 90, []string{"enclave_run_script", "enclave_run_agent"}, 630}, + {"longer future timeout remains covered", true, false, 700, 0, []string{"enclave_run_script"}, 730}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -188,7 +189,7 @@ func TestGenerateEnclaveGatewayContract(t *testing.T) { assert.Contains(t, generated, `"awf-enclave": {`) assert.Contains(t, generated, `"url": "http://awf-enclave-mcp:8080/mcp"`) assert.Contains(t, generated, `"connectTimeout": 120`) - assert.Contains(t, generated, `"toolTimeout": 210`) + assert.Contains(t, generated, `"toolTimeout": 630`) assert.Contains(t, generated, `"tools": ["enclave_run_script", "enclave_run_agent"]`) assert.Contains(t, generated, `Bearer \${AWF_ENCLAVE_MCP_CAPABILITY}`) assert.Contains(t, generated, `openssl rand -hex 32`) From 4550a333f4f31e556c72f4b96f0fbea2e9fe763c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 10 Aug 2026 19:29:37 -0700 Subject: [PATCH 7/8] Fix enclave gateway timeout contract Emit the confirmed fixed 630-second mcpg enforcement bound and test the 540-second executor cap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 245af6c2-3f8d-47e0-b99a-e0144e107a0d --- docs/src/content/docs/reference/enclaves.md | 2 +- pkg/parser/schema_test.go | 18 ++++++++++++++ pkg/workflow/enclaves.go | 26 ++------------------- pkg/workflow/enclaves_test.go | 4 ++-- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md index 124104616d8..084e540e3da 100644 --- a/docs/src/content/docs/reference/enclaves.md +++ b/docs/src/content/docs/reference/enclaves.md @@ -28,6 +28,6 @@ enclaves: Each type can appear at most once. When the same repository appears in both entries, its sensitivity must match because its information budget is shared across executor types. AWF fixes the script enclave network and interpreter and the agent enclave network internally; workflows cannot override those security invariants. -The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available. Its tool timeout is at least 630 seconds, covering AWF's maximum 600-second finite-disclosure timing bucket plus a 30-second cleanup and transport margin. If a future executor timeout exceeds that envelope, the gateway uses the executor timeout plus the same margin. +The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available. It enforces a 630-second tool timeout, covering AWF's maximum 600-second finite-disclosure timing bucket plus a 30-second transport allowance. Executor timeouts are capped at 540 seconds because AWF reserves 60 seconds in the final bucket for processing and cleanup. The gateway timeout is an enforcement bound, not an absolute AWF wall-clock guarantee under pathological host cleanup or scheduler stalls. This compiler contract depends on the unified enclave implementation from `github/gh-aw-firewall#6992`. Until that change is available in an AWF release, pinning an older AWF version will not provide the enclave server. diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index d8ddd93b048..f403813d65b 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -26,6 +26,7 @@ func TestValidateMainWorkflowFrontmatterEnclaves(t *testing.T) { "repos": []any{ map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, }, + "timeout": 540, }, }, } @@ -48,6 +49,23 @@ func TestValidateMainWorkflowFrontmatterEnclaves(t *testing.T) { if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(legacy, "workflow.md"); err == nil { t.Fatal("expected legacy sandbox.enclaves shape to be rejected") } + + tooLong := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "enclaves": []any{ + map[string]any{ + "agent": map[string]any{"model": "gpt-5"}, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 541, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(tooLong, "workflow.md"); err == nil { + t.Fatal("expected enclave timeout above 540 seconds to be rejected") + } } func TestValidateWithSchema(t *testing.T) { diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index 4aac5a0729e..2c12ba0c67a 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -20,8 +20,6 @@ const ( enclaveMCPGatewayContainer = "awmg-mcpg" enclaveMCPConnectTimeout = 120 enclaveMCPReadinessTimeoutMS = 120000 - defaultScriptEnclaveTimeout = 30 - defaultAgentEnclaveTimeout = 120 maxEnclaveTimingBucketSeconds = 600 enclaveMCPTransportAllowance = 30 ) @@ -104,30 +102,10 @@ func enabledEnclaveTools(workflowData *WorkflowData) []string { } func enclaveToolTimeout(workflowData *WorkflowData) int { - maxTimeout := 0 - for _, enclave := range workflowData.Enclaves { - if enclave == nil { - continue - } - if enclave.Script != nil { - timeout := enclave.Timeout - if timeout == 0 { - timeout = defaultScriptEnclaveTimeout - } - maxTimeout = max(maxTimeout, timeout) - } - if enclave.Agent != nil { - timeout := enclave.Timeout - if timeout == 0 { - timeout = defaultAgentEnclaveTimeout - } - maxTimeout = max(maxTimeout, timeout) - } - } - if maxTimeout == 0 { + if !enclavesEnabled(workflowData) { return 0 } - return max(maxTimeout+enclaveMCPTransportAllowance, maxEnclaveTimingBucketSeconds+enclaveMCPTransportAllowance) + return maxEnclaveTimingBucketSeconds + enclaveMCPTransportAllowance } func validateEnclavesConfig(workflowData *WorkflowData) error { diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go index a5d1ada75c5..115856303ed 100644 --- a/pkg/workflow/enclaves_test.go +++ b/pkg/workflow/enclaves_test.go @@ -54,8 +54,8 @@ func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { }{ {"script only defaults cover timing bucket", true, false, 0, 0, []string{"enclave_run_script"}, 630}, {"agent only defaults cover timing bucket", false, true, 0, 0, []string{"enclave_run_agent"}, 630}, - {"custom timeouts cover timing bucket", true, true, 200, 90, []string{"enclave_run_script", "enclave_run_agent"}, 630}, - {"longer future timeout remains covered", true, false, 700, 0, []string{"enclave_run_script"}, 730}, + {"45 second custom timeout covers timing bucket", true, false, 45, 0, []string{"enclave_run_script"}, 630}, + {"540 second maximum timeout covers timing bucket", false, true, 0, 540, []string{"enclave_run_agent"}, 630}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 3fbda5babb12ea288200c18bc88a4a5de1e1b1d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:55:23 +0000 Subject: [PATCH 8/8] Fix lint-errors: add Example: guidance to error messages in enclaves.go; remove copyloopvar redundancies from merge Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../codemod_engine_env_secrets_pure_test.go | 1 - ...s_suffix_to_ai_credits_suffix_pure_test.go | 1 - pkg/workflow/enclaves.go | 22 +++++++++---------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pkg/cli/codemod_engine_env_secrets_pure_test.go b/pkg/cli/codemod_engine_env_secrets_pure_test.go index f1d538a9c09..4c613ba63c3 100644 --- a/pkg/cli/codemod_engine_env_secrets_pure_test.go +++ b/pkg/cli/codemod_engine_env_secrets_pure_test.go @@ -173,7 +173,6 @@ func TestRemoveUnsafeEngineEnvKeys(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() gotLines, gotModified := removeUnsafeEngineEnvKeys(tt.lines, tt.unsafeKeys) diff --git a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_pure_test.go b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_pure_test.go index e24b4cfef5a..a873a1fd5d4 100644 --- a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_pure_test.go +++ b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_pure_test.go @@ -147,7 +147,6 @@ func TestMigrateMessagesEffectiveTokensSuffixToAICreditsSuffix(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() gotLines, gotModified := migrateMessagesEffectiveTokensSuffixToAICreditsSuffix(tt.lines) diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index 2c12ba0c67a..c96550b9635 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -118,49 +118,49 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.BoundedQueries != nil { - return errors.New("enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves") + return errors.New("enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential") } seenTypes := make(map[string]struct{}, len(workflowData.Enclaves)) repositorySensitivities := make(map[string]string) for i, enclave := range workflowData.Enclaves { if enclave == nil { - return fmt.Errorf("enclaves[%d] must be an object", i) + return fmt.Errorf("enclaves[%d] must be an object. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) } enclaveType, ok := enclaveExecutor(enclave) if !ok { - return fmt.Errorf("enclaves[%d] must contain exactly one of script or agent", i) + return fmt.Errorf("enclaves[%d] must contain exactly one of script or agent. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) } if _, ok := seenTypes[enclaveType]; ok { - return fmt.Errorf("enclaves contains duplicate executor type %q; each type may appear at most once", enclaveType) + return fmt.Errorf("enclaves contains duplicate executor type %q; each type may appear at most once. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", enclaveType) } seenTypes[enclaveType] = struct{}{} if enclaveType == "agent" && enclave.Agent.Model == "" { - return fmt.Errorf("enclaves[%d].agent.model is required", i) + return fmt.Errorf("enclaves[%d].agent.model is required. Example:\n\nenclaves:\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) } if len(enclave.Repos) == 0 { - return fmt.Errorf("enclaves[%d].repos must contain at least one repository", i) + return fmt.Errorf("enclaves[%d].repos must contain at least one repository. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) } seenInEnclave := make(map[string]struct{}, len(enclave.Repos)) for j, repo := range enclave.Repos { if repo == nil { - return fmt.Errorf("enclaves[%d].repos[%d] must be an object", i, j) + return fmt.Errorf("enclaves[%d].repos[%d] must be an object. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) } parts := strings.SplitN(repo.Repo, "/", 2) if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { - return fmt.Errorf("enclaves[%d].repos[%d].repo must be a bare owner/repository slug (e.g. org/my-repo)", i, j) + return fmt.Errorf("enclaves[%d].repos[%d].repo must be a bare owner/repository slug (e.g. org/my-repo). Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) } key := strings.ToLower(repo.Repo) if _, ok := seenInEnclave[key]; ok { - return fmt.Errorf("enclaves[%d].repos contains duplicate repository %q", i, repo.Repo) + return fmt.Errorf("enclaves[%d].repos contains duplicate repository %q; each repository may appear at most once per enclave entry. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, repo.Repo) } seenInEnclave[key] = struct{}{} switch repo.Sensitivity { case "public", "internal", "confidential", "sealed": default: - return fmt.Errorf("enclaves[%d].repos[%d].sensitivity must be public, internal, confidential, or sealed", i, j) + return fmt.Errorf("enclaves[%d].repos[%d].sensitivity must be public, internal, confidential, or sealed. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) } if sensitivity, ok := repositorySensitivities[key]; ok && sensitivity != repo.Sensitivity { - return fmt.Errorf("repository %q must use the same sensitivity across enclave types", repo.Repo) + return fmt.Errorf("repository %q must use the same sensitivity across enclave types; all enclave entries for a given repository must declare the same sensitivity. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", repo.Repo) } repositorySensitivities[key] = repo.Sensitivity }