Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions pkg/workflow/checkout_manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"fmt"
"strings"

"github.com/github/gh-aw/pkg/logger"
Expand Down Expand Up @@ -283,6 +284,25 @@ func (cm *CheckoutManager) HasAppAuth() bool {
return false
}

// CheckoutAppTokenOutputs returns a map of activation job output names to their
// step token expressions, for all checkouts using app authentication.
// Output names follow the pattern "checkout_app_token_{index}".
// Step ID expressions follow the pattern "steps.checkout-app-token-{index}.outputs.token".
// These outputs are set by the activation job so the agent job can reference them as
// needs.activation.outputs.checkout_app_token_{index}.
func (cm *CheckoutManager) CheckoutAppTokenOutputs() map[string]string {
outputs := make(map[string]string)
for i, entry := range cm.ordered {
if entry.githubApp == nil {
continue
}
outputName := fmt.Sprintf("checkout_app_token_%d", i)
stepID := fmt.Sprintf("checkout-app-token-%d", i)
outputs[outputName] = fmt.Sprintf("${{ steps.%s.outputs.token }}", stepID)
}
return outputs
}

// HasExternalRootCheckout returns true if any checkout entry targets an external
// repository (non-empty repository field) and writes to the workspace root (empty path).
// When such a checkout exists, the workspace root is replaced with the external
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/checkout_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ func TestDefaultCheckoutWithAppAuth(t *testing.T) {
})
lines := cm.GenerateDefaultCheckoutStep(false, "", getPin)
combined := strings.Join(lines, "")
assert.Contains(t, combined, "steps.checkout-app-token-0.outputs.token", "checkout should reference app token step")
assert.Contains(t, combined, "needs.activation.outputs.checkout_app_token_0", "checkout should reference app token step")
})
}

Expand All @@ -908,7 +908,7 @@ func TestAdditionalCheckoutWithAppAuth(t *testing.T) {
})
lines := cm.GenerateAdditionalCheckoutSteps(getPin)
combined := strings.Join(lines, "")
assert.Contains(t, combined, "steps.checkout-app-token-1.outputs.token", "additional checkout should reference app token at index 1")
assert.Contains(t, combined, "needs.activation.outputs.checkout_app_token_1", "additional checkout should reference app token at index 1")
assert.Contains(t, combined, "other/repo", "should reference the additional repo")
})
}
Expand Down
12 changes: 7 additions & 5 deletions pkg/workflow/checkout_step_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission

// GenerateCheckoutAppTokenInvalidationSteps generates token invalidation steps
// for all checkout entries that use app authentication.
// The tokens were minted in the activation job and are referenced via
// needs.activation.outputs.checkout_app_token_{index}.
func (cm *CheckoutManager) GenerateCheckoutAppTokenInvalidationSteps(c *Compiler) []string {
var steps []string
for i, entry := range cm.ordered {
Expand All @@ -41,9 +43,9 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenInvalidationSteps(c *Compiler
}
checkoutManagerLog.Printf("Generating app token invalidation step for checkout index=%d", i)
rawSteps := c.buildGitHubAppTokenInvalidationStep()
stepID := fmt.Sprintf("checkout-app-token-%d", i)
outputName := fmt.Sprintf("checkout_app_token_%d", i)
for _, step := range rawSteps {
modified := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps."+stepID+".outputs.token")
modified := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "needs.activation.outputs."+outputName)
// Update step name to indicate it's for checkout
modified = strings.ReplaceAll(modified, "Invalidate GitHub App token", fmt.Sprintf("Invalidate checkout app token (%d)", i))
steps = append(steps, modified)
Expand Down Expand Up @@ -159,7 +161,7 @@ func (cm *CheckoutManager) GenerateDefaultCheckoutStep(
if override.githubApp != nil {
// The default checkout is always at index 0 in the ordered list
//nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential
effectiveOverrideToken = "${{ steps.checkout-app-token-0.outputs.token }}"
effectiveOverrideToken = "${{ needs.activation.outputs.checkout_app_token_0 }}"
}
if effectiveOverrideToken != "" {
fmt.Fprintf(&sb, " token: %s\n", effectiveOverrideToken)
Expand Down Expand Up @@ -226,7 +228,7 @@ func generateCheckoutStepLines(entry *resolvedCheckout, index int, getActionPin
effectiveToken := entry.token
if entry.githubApp != nil {
//nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential
effectiveToken = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index)
effectiveToken = fmt.Sprintf("${{ needs.activation.outputs.checkout_app_token_%d }}", index)
}
if effectiveToken != "" {
fmt.Fprintf(&sb, " token: %s\n", effectiveToken)
Expand Down Expand Up @@ -314,7 +316,7 @@ func generateFetchStepLines(entry *resolvedCheckout, index int) string {
token := entry.token
if entry.githubApp != nil {
//nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential
token = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index)
token = fmt.Sprintf("${{ needs.activation.outputs.checkout_app_token_%d }}", index)
}
if token == "" {
token = getEffectiveGitHubToken("")
Expand Down
27 changes: 27 additions & 0 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"strings"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -120,6 +121,32 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
outputs["activation_app_token_minting_failed"] = "${{ steps.activation-app-token.outcome == 'failure' }}"
}

// Mint the GitHub MCP app token in the activation job so that the agent job never
// receives the app-id / private-key secrets. The minted token is exposed as a job
// output and consumed by the agent job via needs.activation.outputs.github_mcp_app_token.
if data.ParsedTools != nil && data.ParsedTools.GitHub != nil && data.ParsedTools.GitHub.GitHubApp != nil {
steps = append(steps, c.generateGitHubMCPAppTokenMintingSteps(data)...)
outputs["github_mcp_app_token"] = "${{ steps.github-mcp-app-token.outputs.token }}"
}

// Mint checkout app tokens in the activation job so that the agent job never
// receives the app-id / private-key secrets. Each token is exposed as a job output
// and consumed by the agent job via needs.activation.outputs.checkout_app_token_{index}.
checkoutMgrForActivation := NewCheckoutManager(data.CheckoutConfigs)
if checkoutMgrForActivation.HasAppAuth() {
compilerActivationJobLog.Print("Generating checkout app token minting steps in activation job")
var checkoutPermissions *Permissions
if data.Permissions != "" {
parser := NewPermissionsParser(data.Permissions)
checkoutPermissions = parser.ToPermissions()
} else {
checkoutPermissions = NewPermissions()
}
checkoutAppTokenSteps := checkoutMgrForActivation.GenerateCheckoutAppTokenSteps(c, checkoutPermissions)
steps = append(steps, checkoutAppTokenSteps...)
maps.Copy(outputs, checkoutMgrForActivation.CheckoutAppTokenOutputs())
}

// Add reaction step right after generate_aw_info so it is shown to the user as fast as
// possible. generate_aw_info runs first so its data is captured even if the reaction fails.
// This runs in the activation job so it can use any configured github-token or github-app.
Expand Down
54 changes: 34 additions & 20 deletions pkg/workflow/compiler_github_mcp_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,18 @@ func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder,
yaml.WriteString(" await determineAutomaticLockdown(github, context, core);\n")
}

// generateGitHubMCPAppTokenMintingStep generates a step to mint a GitHub App token for GitHub MCP server
// This step is added when:
// - GitHub tool is enabled with app configuration
// The step mints an installation access token with permissions matching the agent job permissions
func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) {
// generateGitHubMCPAppTokenMintingSteps returns the YAML steps to mint a GitHub App token
// for the GitHub MCP server. The steps are generated with id: github-mcp-app-token and
// permissions derived from the agent job's declared permissions plus any extra permissions
// configured under tools.github.github-app.permissions.
//
// The returned steps are intended to be added to the activation job so that the
// app-id / private-key secrets never reach the agent job. The minted token is then
// consumed in the agent job via needs.activation.outputs.github_mcp_app_token.
func (c *Compiler) generateGitHubMCPAppTokenMintingSteps(data *WorkflowData) []string {
// Check if GitHub tool has app configuration
if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil {
return
return nil
}

app := data.ParsedTools.GitHub.GitHubApp
Expand Down Expand Up @@ -125,18 +129,20 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d
}

// Generate the token minting step using the existing helper from safe_outputs_app.go
steps := c.buildGitHubAppTokenMintStep(app, permissions, "")
rawSteps := c.buildGitHubAppTokenMintStep(app, permissions, "")

// Modify the step ID to differentiate from safe-outputs app token
// Replace "safe-outputs-app-token" with "github-mcp-app-token"
for _, step := range steps {
modifiedStep := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token")
yaml.WriteString(modifiedStep)
// Replace the default step ID with github-mcp-app-token to differentiate it from
// the safe-outputs app token.
var steps []string
for _, step := range rawSteps {
steps = append(steps, strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token"))
}
return steps
}

// generateGitHubMCPAppTokenInvalidationStep generates a step to invalidate the GitHub App token for GitHub MCP server
// This step always runs (even on failure) to ensure tokens are properly cleaned up
// This step always runs (even on failure) to ensure tokens are properly cleaned up.
// The token was minted in the activation job and is referenced via needs.activation.outputs.github_mcp_app_token.
func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) {
// Check if GitHub tool has app configuration
if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil {
Expand All @@ -145,14 +151,22 @@ func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Build

githubConfigLog.Print("Generating GitHub App token invalidation step for GitHub MCP server")

// Generate the token invalidation step using the existing helper from safe_outputs_app.go
steps := c.buildGitHubAppTokenInvalidationStep()
// The token was minted in the activation job; reference it via needs.activation.outputs.
const tokenExpr = "needs.activation.outputs.github_mcp_app_token"

// Modify the step references to use github-mcp-app-token instead of safe-outputs-app-token
for _, step := range steps {
modifiedStep := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps.github-mcp-app-token.outputs.token")
yaml.WriteString(modifiedStep)
}
yaml.WriteString(" - name: Invalidate GitHub App token\n")
fmt.Fprintf(yaml, " if: always() && %s != ''\n", tokenExpr)
yaml.WriteString(" env:\n")
fmt.Fprintf(yaml, " TOKEN: ${{ %s }}\n", tokenExpr)
yaml.WriteString(" run: |\n")
yaml.WriteString(" echo \"Revoking GitHub App installation token...\"\n")
yaml.WriteString(" # GitHub CLI will auth with the token being revoked.\n")
yaml.WriteString(" gh api \\\n")
yaml.WriteString(" --method DELETE \\\n")
yaml.WriteString(" -H \"Authorization: token $TOKEN\" \\\n")
yaml.WriteString(" /installation/token || echo \"Token revoke may already be expired.\"\n")
yaml.WriteString(" \n")
yaml.WriteString(" echo \"Token invalidation step complete.\"\n")
}

// generateParseGuardVarsStep generates a step that parses the blocked-users, trusted-users, and
Expand Down
11 changes: 10 additions & 1 deletion pkg/workflow/compiler_main_job.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"errors"
"fmt"
"maps"
"slices"
Expand Down Expand Up @@ -67,8 +68,16 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
return nil, fmt.Errorf("failed to generate main job steps: %w", err)
}

// Split the steps content into individual step entries
// Compiler invariant: the agent job must not mint GitHub App tokens.
// All token minting (create-github-app-token) must happen in the activation job so that
// app-id / private-key secrets never reach the agent's environment. Fail fast during
// compilation if this invariant is violated to catch regressions early.
stepsContent := stepBuilder.String()
if strings.Contains(stepsContent, "create-github-app-token") {
return nil, errors.New("compiler invariant violated: agent job contains a GitHub App token minting step (create-github-app-token); token minting must only occur in the activation job")
}

// Split the steps content into individual step entries
if stepsContent != "" {
steps = append(steps, stepsContent)
}
Expand Down
20 changes: 0 additions & 20 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,6 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
checkoutMgr.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")
}

// Generate GitHub App token minting steps for checkouts with app auth
// These must be emitted BEFORE the checkout steps that reference them
if checkoutMgr.HasAppAuth() {
compilerYamlLog.Print("Generating checkout app token minting steps")
var permissions *Permissions
if data.Permissions != "" {
parser := NewPermissionsParser(data.Permissions)
permissions = parser.ToPermissions()
} else {
permissions = NewPermissions()
}
appTokenSteps := checkoutMgr.GenerateCheckoutAppTokenSteps(c, permissions)
for _, step := range appTokenSteps {
yaml.WriteString(step)
}
}

// Add checkout step first if needed
if needsCheckout {
// Emit the default workspace checkout, applying any user-supplied overrides
Expand Down Expand Up @@ -283,9 +266,6 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
// Add step to parse blocked-users and approval-labels guard variables into JSON arrays
c.generateParseGuardVarsStep(yaml, data)

// Add GitHub MCP app token minting step if configured
c.generateGitHubMCPAppTokenMintingStep(yaml, data)

// Stop DIFC proxy before starting the MCP gateway. The proxy must be stopped first
// to avoid double-filtering: the gateway uses the same guard policy for the agent phase.
c.generateStopDIFCProxyStep(yaml, data)
Expand Down
6 changes: 4 additions & 2 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,11 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"
}

if hasGitHubTool(workflowData.ParsedTools) {
// If GitHub App is configured, use the app token (overrides custom and default tokens)
// If GitHub App is configured, use the app token minted in the activation job.
// The token is passed via needs.activation.outputs to keep app-id/private-key
// secrets out of the agent job.
if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.GitHubApp != nil {
env["GITHUB_MCP_SERVER_TOKEN"] = "${{ steps.github-mcp-app-token.outputs.token }}"
env["GITHUB_MCP_SERVER_TOKEN"] = "${{ needs.activation.outputs.github_mcp_app_token }}"
} else {
customGitHubToken := getGitHubToken(workflowData.Tools["github"])
// Use effective token with precedence: custom > default
Expand Down
Loading
Loading