Skip to content
5 changes: 5 additions & 0 deletions .changeset/patch-exclude-job-output-credential-env-vars.md

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

18 changes: 15 additions & 3 deletions pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -624,18 +628,25 @@ 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)
acc.appendFeaturesField(fm)
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) {
Expand Down Expand Up @@ -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,
}
}
Expand Down
34 changes: 34 additions & 0 deletions pkg/parser/import_field_extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
32 changes: 21 additions & 11 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
})
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
}
Expand Down
115 changes: 115 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
31 changes: 31 additions & 0 deletions pkg/workflow/secret_extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading