diff --git a/.changeset/patch-exclude-job-output-credential-env-vars.md b/.changeset/patch-exclude-job-output-credential-env-vars.md new file mode 100644 index 00000000000..c579fdfaaf7 --- /dev/null +++ b/.changeset/patch-exclude-job-output-credential-env-vars.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Security: exclude env vars whose values are job-output expressions (`${{ needs.JOB.outputs.OUTPUT }}`) from the agent container sandbox via AWF `--exclude-env`, in addition to the existing `${{ secrets.* }}` check. This closes the gap where migrating from long-lived PATs stored as secrets to ephemeral tokens generated by an upstream job (a common GitHub App pattern) would silently drop the sandbox exclusion, leaving the token visible inside the AWF container. diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index e37ac2e8d0d..8e9278d31a3 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -85,6 +85,9 @@ type importAccumulator struct { mergedMaxTurnCacheMisses string mergedMaxAICredits string mergedMaxDailyAICredits string + // Union of excluded-env lists from all imported files (deduplicated). + excludedEnv []string + excludedEnvSet map[string]bool // Best-effort sub-agent frontmatter warnings collected during BFS traversal. warnings []string } @@ -106,6 +109,7 @@ func newImportAccumulator() *importAccumulator { importInputs: make(map[string]any), envSources: make(map[string]string), sandboxAgentMountsSet: make(map[string]bool), + excludedEnvSet: make(map[string]bool), } } @@ -624,11 +628,11 @@ func (acc *importAccumulator) extractStepAndJobFields(fm map[string]any, importP } // extractFeatureAndObservabilityFields extracts labels, cache, feature flags, model -// aliases, the run-install-scripts flag, and observability configuration from the -// frontmatter map. +// aliases, the run-install-scripts flag, observability configuration, and excluded-env +// from the frontmatter map. // // Side effects: acc.labels, acc.labelsSet, acc.caches, acc.features, acc.models, -// acc.runInstallScripts, acc.observabilityConfigs. +// acc.runInstallScripts, acc.observabilityConfigs, acc.excludedEnv, acc.excludedEnvSet. func (acc *importAccumulator) extractFeatureAndObservabilityFields(fm map[string]any, fullPath string) { acc.mergeLabels(fm) acc.appendCacheField(fm) @@ -636,6 +640,13 @@ func (acc *importAccumulator) extractFeatureAndObservabilityFields(fm map[string acc.appendModelsField(fm, fullPath) acc.extractRunInstallScripts(fm, fullPath) acc.appendObservabilityField(fm, fullPath) + acc.mergeExcludedEnv(fm) +} + +func (acc *importAccumulator) mergeExcludedEnv(fm map[string]any) { + mergeJSONStringListField(fm, "excluded-env", "[]", acc.excludedEnvSet, &acc.excludedEnv, func(m map[string]any, field string) (string, error) { + return extractFieldJSONFromMap(m, field, "[]") + }) } func (acc *importAccumulator) mergeLabels(fm map[string]any) { @@ -910,6 +921,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import MergedMaxTurnCacheMisses: acc.mergedMaxTurnCacheMisses, MergedMaxAICredits: acc.mergedMaxAICredits, MergedMaxDailyAICredits: acc.mergedMaxDailyAICredits, + MergedExcludedEnv: acc.excludedEnv, Warnings: acc.warnings, } } diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index 324970ceec5..fc94d21660f 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -832,3 +832,37 @@ func TestAppendModelsField_ProvidersAndAliasesBothExtracted(t *testing.T) { require.Len(t, acc.models, 1) assert.Equal(t, []string{"gpt-5"}, acc.models[0]["agent"]) } + +func TestMergeExcludedEnv_SingleImport(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "excluded-env": []any{"TOKEN_A", "TOKEN_B"}, + } + acc.mergeExcludedEnv(fm) + assert.Equal(t, []string{"TOKEN_A", "TOKEN_B"}, acc.excludedEnv) +} + +func TestMergeExcludedEnv_Deduplication(t *testing.T) { + acc := newImportAccumulator() + fm1 := map[string]any{"excluded-env": []any{"TOKEN_A", "TOKEN_B"}} + fm2 := map[string]any{"excluded-env": []any{"TOKEN_B", "TOKEN_C"}} + acc.mergeExcludedEnv(fm1) + acc.mergeExcludedEnv(fm2) + // TOKEN_B should appear only once + assert.Equal(t, []string{"TOKEN_A", "TOKEN_B", "TOKEN_C"}, acc.excludedEnv) +} + +func TestMergeExcludedEnv_EmptyOrMissing(t *testing.T) { + acc := newImportAccumulator() + acc.mergeExcludedEnv(map[string]any{}) + acc.mergeExcludedEnv(map[string]any{"excluded-env": []any{}}) + assert.Empty(t, acc.excludedEnv) +} + +func TestToImportsResult_MergedExcludedEnv(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{"excluded-env": []any{"MY_TOKEN"}} + acc.mergeExcludedEnv(fm) + result := acc.toImportsResult(nil) + assert.Equal(t, []string{"MY_TOKEN"}, result.MergedExcludedEnv) +} diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 5bd0da66b45..24a854b949f 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -72,6 +72,7 @@ type ImportsResult struct { MergedMaxTurnCacheMisses string // First max-turn-cache-misses value found across all imports (JSON-encoded, first-wins) MergedMaxAICredits string // First max-ai-credits value found across all imports (JSON-encoded, first-wins) MergedMaxDailyAICredits string // First max-daily-ai-credits value found across all imports (JSON-encoded, first-wins) + MergedExcludedEnv []string // Union of excluded-env lists from all imports (deduplicated, used to extend the main workflow's excluded-env) ImportedFiles []string // List of imported file paths (for manifest) AgentFile string // Path to custom agent file (if imported) AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 1d7f6243811..6501bb02d3e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -11332,6 +11332,16 @@ "description": "Control whether the compile-agentic version update check runs in the activation job. When true (default), the activation job downloads config.json from the gh-aw repository and verifies the compiled version is not blocked and meets the minimum supported version. Set to false to disable the check (not allowed in strict mode). See: https://github.github.com/gh-aw/reference/frontmatter/#check-for-updates", "examples": [true, false] }, + "excluded-env": { + "type": "array", + "description": "Optional list of environment variable names to unconditionally exclude from the AWF agent container via --exclude-env. Use when an env var is set from a source the compiler cannot auto-detect as credential-bearing (e.g. a workflow_dispatch input carrying a token). Names are deduplicated and merged with those auto-detected from secrets.* and needs.*.outputs.* references.", + "items": { + "type": "string", + "minLength": 1, + "description": "Environment variable name to exclude from the agent container." + }, + "examples": [["MY_DISPATCH_TOKEN", "ANOTHER_SENSITIVE_VAR"], ["GH_TOKEN"]] + }, "mcp-scripts": { "type": "object", "description": "MCP Scripts configuration for defining custom lightweight MCP tools as JavaScript, shell scripts, or Python scripts. Tools are mounted in an MCP server and have access to secrets specified by the user. Only one of 'script' (JavaScript), 'run' (shell), or 'py' (Python) must be specified per tool.", diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 2fb24aab3d2..fcb0b5db3d8 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -889,8 +889,10 @@ func WrapCommandInShell(command string) string { // 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. // -// Only env var names whose step-env values WILL contain a ${{ secrets.* }} reference are -// included, so non-secret vars (e.g. GH_DEBUG: "1" in mcp-scripts) are never excluded. +// 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 @@ -900,9 +902,10 @@ func WrapCommandInShell(command string) string { // - 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.* }} -// - engine.env var names whose values contain ${{ secrets.* }} -// - agent.env var names whose values 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 { }) @@ -936,32 +939,33 @@ func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames addUnique(varName) } - // mcp-scripts env vars: only add those whose configured values contain a secret reference. + // 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.") { + if strings.Contains(envValue, "${{ secrets.") || ContainsJobOutputExpr(envValue) { addUnique(envName) } } } } - // engine.env vars that contain a secret reference. + // 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.") { + if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { addUnique(varName) } } } - // agent.env vars that contain a secret reference. + // 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.") { + if strings.Contains(varValue, "${{ secrets.") || ContainsJobOutputExpr(varValue) { addUnique(varName) } } @@ -973,6 +977,12 @@ func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames addUnique("GH_TOKEN") } + // 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 } diff --git a/pkg/workflow/awf_helpers_test.go b/pkg/workflow/awf_helpers_test.go index bf3acd58585..f4051f79f7a 100644 --- a/pkg/workflow/awf_helpers_test.go +++ b/pkg/workflow/awf_helpers_test.go @@ -1205,6 +1205,121 @@ func TestComputeAWFExcludeEnvVarNames(t *testing.T) { 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: "empty excluded-env has no effect", + workflowData: &WorkflowData{ + ExcludedEnv: []string{}, + }, + coreSecretVarNames: []string{}, + want: []string{}, + }, } for _, tt := range tests { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index a812274e5d1..57b989f8d25 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -418,4 +418,10 @@ type FrontmatterConfig struct { // Can be a plain list (shorthand) or an object with a questions list and optional // engine-config / runs-on overrides. Evals any `json:"evals,omitempty"` + + // ExcludedEnv lists additional environment variable names that must be excluded from + // the agent container via AWF's --exclude-env flag. Use this when an env var is set + // from a source that the compiler cannot automatically detect as credential-bearing + // (e.g. a workflow_dispatch input that carries a token). + ExcludedEnv []string `json:"excluded-env,omitempty"` } diff --git a/pkg/workflow/secret_extraction.go b/pkg/workflow/secret_extraction.go index 00d4f4ae03d..e5215ac0e04 100644 --- a/pkg/workflow/secret_extraction.go +++ b/pkg/workflow/secret_extraction.go @@ -15,6 +15,16 @@ var secretLog = logger.New("workflow:secret_extraction") var ( // secretsNamePattern extracts the secret variable name from an expression secretsNamePattern = regexp.MustCompile(`secrets\.([A-Z_][A-Z0-9_]*)`) + + // jobOutputBodyDotPattern matches needs.JOB.outputs.OUTPUT anywhere within an expression body + // using dot notation. The word boundary ensures we don't match partial identifiers. + // Used inside ContainsJobOutputExpr after expressions have been extracted by InlineExpressionPattern. + jobOutputBodyDotPattern = regexp.MustCompile(`\bneeds\.[A-Za-z_][A-Za-z0-9_-]*\.outputs\.[A-Za-z_][A-Za-z0-9_-]*`) + + // jobOutputBodyBracketPattern matches bracket-notation job-output references anywhere within + // an expression body: needs['JOB'].outputs, needs["JOB"].outputs, or needs['JOB']['outputs']. + // Used inside ContainsJobOutputExpr alongside jobOutputBodyDotPattern. + jobOutputBodyBracketPattern = regexp.MustCompile(`\bneeds\[['"][A-Za-z_][A-Za-z0-9_-]*['"]\](?:\.outputs\b|\[['"]outputs['"]\])`) ) // SecretExpression represents a parsed secret expression @@ -92,6 +102,27 @@ func ExtractSecretsFromMap(values map[string]string) map[string]string { return allSecrets } +// ContainsJobOutputExpr reports whether value contains a GitHub Actions job-output +// expression. Detected forms include: +// - Dot notation: ${{ needs.JOB.outputs.OUTPUT }} +// - Sub-expression: ${{ github.ref && needs.JOB.outputs.OUTPUT }} +// - Bracket notation: ${{ needs['JOB'].outputs['OUTPUT'] }} +// - Double-quote: ${{ needs["JOB"]["outputs"]["OUTPUT"] }} +// +// These expressions carry values computed at runtime by an upstream job and are often +// sensitive (e.g. ephemeral GitHub App tokens), so they must be treated the same as +// ${{ secrets.* }} references when deciding whether to exclude an env var from the +// agent container via AWF --exclude-env. +func ContainsJobOutputExpr(value string) bool { + expressions := InlineExpressionPattern.FindAllString(value, -1) + for _, expr := range expressions { + if jobOutputBodyDotPattern.MatchString(expr) || jobOutputBodyBracketPattern.MatchString(expr) { + return true + } + } + return false +} + // ExtractEnvExpressionsFromMap extracts all env variable expressions from a map of string values // Returns a map of environment variable names to their full env expressions (including fallbacks) // Example: diff --git a/pkg/workflow/secret_extraction_test.go b/pkg/workflow/secret_extraction_test.go index 368b0d72b7b..708d85ea5f3 100644 --- a/pkg/workflow/secret_extraction_test.go +++ b/pkg/workflow/secret_extraction_test.go @@ -586,3 +586,89 @@ func TestFormatInputNameAsEnvVar(t *testing.T) { }) } } + +func TestContainsJobOutputExpr(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + { + name: "pure job output expression", + value: "${{ needs.fetch_token.outputs.token }}", + want: true, + }, + { + name: "job output with extra whitespace", + value: "${{ needs.fetch_token.outputs.token }}", + want: true, + }, + { + name: "job output embedded in a larger string", + value: "Bearer ${{ needs.auth_job.outputs.access_token }}", + want: true, + }, + { + name: "secret expression is not a job output", + value: "${{ secrets.GH_TOKEN }}", + want: false, + }, + { + name: "static value is not a job output", + value: "https://api.example.com", + want: false, + }, + { + name: "needs.result (no .outputs.) is not a job output", + value: "${{ needs.some_job.result }}", + want: false, + }, + { + name: "github token expression is not a job output", + value: "${{ github.token }}", + want: false, + }, + { + name: "job output with hyphenated job name", + value: "${{ needs.fetch-token.outputs.token }}", + want: true, + }, + { + name: "needs not first token in expression (sub-expression with &&)", + value: "${{ github.ref && needs.auth.outputs.token }}", + want: true, + }, + { + name: "needs not first token in expression (sub-expression with ||)", + value: "${{ env.FALLBACK || needs.mint.outputs.gh_token }}", + want: true, + }, + { + name: "bracket notation with single quotes", + value: "${{ needs['auth'].outputs['token'] }}", + want: true, + }, + { + name: "bracket notation with double quotes", + value: `${{ needs["auth"].outputs["token"] }}`, + want: true, + }, + { + name: "full bracket notation (job and outputs key both in brackets)", + value: "${{ needs['auth']['outputs']['token'] }}", + want: true, + }, + { + name: "bracket notation embedded after other token", + value: "${{ github.token || needs['mint'].outputs['gh_token'] }}", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ContainsJobOutputExpr(tt.value) + assert.Equal(t, tt.want, got, "ContainsJobOutputExpr(%q)", tt.value) + }) + } +} diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index b3373f6e304..c08b0635f6b 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -171,6 +171,16 @@ func (c *Compiler) buildInitialWorkflowData( workflowData.ModelPolicyBlocked = disallowedModels } + // Populate explicitly excluded env var names: union of imported workflows' excluded-env + // and the main workflow's excluded-env. Deduplicate and sort for stability. + var mainExcludedEnv []string + if toolsResult.parsedFrontmatter != nil { + mainExcludedEnv = toolsResult.parsedFrontmatter.ExcludedEnv + } + if names := mergeExcludedEnvVarNames(importsResult.MergedExcludedEnv, mainExcludedEnv); len(names) > 0 { + workflowData.ExcludedEnv = names + } + return workflowData } @@ -456,6 +466,31 @@ func resolveInlinedImports(rawFrontmatter map[string]any) bool { return ParseBoolFromConfig(rawFrontmatter, "inlined-imports", nil) } +// mergeExcludedEnvVarNames unions the imported and main excluded-env name lists, +// deduplicates entries across both sources, and returns a sorted slice for +// deterministic output. +func mergeExcludedEnvVarNames(fromImports, fromMain []string) []string { + if len(fromImports) == 0 && len(fromMain) == 0 { + return nil + } + seen := make(map[string]bool, len(fromImports)+len(fromMain)) + merged := make([]string, 0, len(fromImports)+len(fromMain)) + for _, name := range fromImports { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + for _, name := range fromMain { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + sort.Strings(merged) + return merged +} + // extractYAMLSections extracts YAML configuration sections from frontmatter func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData *WorkflowData) error { workflowBuilderLog.Print("Extracting YAML sections from frontmatter") diff --git a/pkg/workflow/workflow_builder_model_policy_test.go b/pkg/workflow/workflow_builder_model_policy_test.go index 7ef8bc96064..8b01f05886c 100644 --- a/pkg/workflow/workflow_builder_model_policy_test.go +++ b/pkg/workflow/workflow_builder_model_policy_test.go @@ -130,3 +130,34 @@ func TestExtractMainModelCostsOverlay_ExtractsOnlyProvidersAndExcludesPolicyKeys assert.Contains(t, costs, "providers") assert.NotContains(t, costs, "allowed") } + +func TestMergeExcludedEnvVarNames_UnionizesAndSorts(t *testing.T) { + got := mergeExcludedEnvVarNames( + []string{"TOKEN_B", "TOKEN_A"}, + []string{"TOKEN_C", "TOKEN_A"}, + ) + assert.Equal(t, []string{"TOKEN_A", "TOKEN_B", "TOKEN_C"}, got) +} + +func TestMergeExcludedEnvVarNames_ImportsOnly(t *testing.T) { + got := mergeExcludedEnvVarNames([]string{"IMPORT_TOKEN"}, nil) + assert.Equal(t, []string{"IMPORT_TOKEN"}, got) +} + +func TestMergeExcludedEnvVarNames_MainOnly(t *testing.T) { + got := mergeExcludedEnvVarNames(nil, []string{"MAIN_TOKEN"}) + assert.Equal(t, []string{"MAIN_TOKEN"}, got) +} + +func TestMergeExcludedEnvVarNames_BothEmpty(t *testing.T) { + got := mergeExcludedEnvVarNames(nil, nil) + assert.Nil(t, got) +} + +func TestMergeExcludedEnvVarNames_DeduplicatesAcrossSources(t *testing.T) { + got := mergeExcludedEnvVarNames( + []string{"SHARED", "IMPORT_ONLY"}, + []string{"SHARED", "MAIN_ONLY"}, + ) + assert.Equal(t, []string{"IMPORT_ONLY", "MAIN_ONLY", "SHARED"}, got) +} diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index fa8bee49927..d8610ee1f3a 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -192,6 +192,7 @@ type WorkflowData struct { ModelPolicyBlocked []string // merged models.blocked policy list (union across imports + main frontmatter) ActionPinMappings map[string]string // action-pin redirect table from aw.json action_pins: maps "owner/repo@version" → "owner/repo@version" Evals *EvalsConfig // BinEval evaluation configuration parsed from frontmatter evals field + ExcludedEnv []string // additional env var names to exclude from agent container via AWF --exclude-env (from frontmatter excluded-env field) } // PinContext returns an actionpins.PinContext backed by this WorkflowData.