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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ADR-66039: Trust Compiler-Generated Wildcard GitHub App Token Steps in Strict Mode

**Date**: 2026-02-23
**Status**: Draft
**Deciders**: Unknown (PR #66039 author and reviewers)

---

### Context

Strict mode validation (`validateAppTokenPermissions`) requires every `actions/create-github-app-token` step to carry an explicit `repositories` input so that minted tokens are scoped to a known repository set. When a workflow declares `repositories: ["*"]` for a `github-app` (either under `tools.github` or under `safe-outputs`), the compiler intentionally omits the `repositories` input from the generated step, because the GitHub App token action interprets "no `repositories` input" as "all repositories the app is installed on". The validator could not distinguish this deliberate, user-declared wildcard from an accidentally unscoped hand-written step, so documented cross-repository access failed to compile under `strict: true` (see #65814). The fix must not weaken scoping checks for token steps the compiler did not generate, nor relax the separate requirement for explicit `permission-*` inputs.

### Decision

We will have the compiler record, at generation time, the identity of each token step it emits from an explicit `repositories: ["*"]` configuration, and have strict-mode validation consult that record before flagging a missing `repositories` input. The record is a `map[appTokenStepKey]bool` on the `Compiler` (keyed by step `id`, `client-id`, and `private-key`), populated in `buildGitHubAppTokenMintStepWithMeta` and reset at the start of each `CompileWorkflowData` run. The primary driver is correctness with minimal blast radius: provenance is known precisely at the point of generation, so no heuristic re-inference from the emitted YAML is needed.

### Alternatives Considered

#### Alternative 1: Emit `repositories: "*"` into the generated step

Keeping an explicit wildcard value in the generated YAML would satisfy the existing validator with no compiler state at all, and would make intent visible in the `.lock.yml`. It was rejected because `actions/create-github-app-token` does not treat `"*"` as a wildcard — it would be interpreted as a literal repository name — so this would change runtime behaviour and break the documented cross-repository access path.

#### Alternative 2: Thread wildcard intent through `WorkflowData` / validation inputs instead of compiler state

Rather than mutable `Compiler` state, the wildcard flag could be carried on the workflow data structures already passed into validation. This is arguably cleaner (no reset-per-compile hazard), but it requires touching more types and call sites across the generation and validation paths. It was a close call; the compiler-field approach was chosen for a smaller diff, with the `c.wildcardAppTokenSteps = nil` reset in `CompileWorkflowData` guarding batch-mode leakage.

#### Alternative 3: Skip the `repositories` check entirely for compiler-generated steps

The validator could exempt any step whose `id` matches a known generated prefix (e.g. `github-mcp-app-token`, `safe-outputs-app-token`). This is simpler but over-broad: it would also exempt generated steps whose configuration did *not* request a wildcard, silently dropping a real scoping check. Rejected as a loss of validation coverage.

### Consequences

#### Positive
- Workflows that legitimately declare `repositories: ["*"]` now compile under `strict: true` without warnings, unblocking documented cross-repository GitHub tools and safe outputs (#65814).
- Scoping enforcement is preserved for all hand-written and non-wildcard token steps; regression tests cover both the `github` tool and `safe-outputs` wildcard shapes, and assert that unrelated steps still fail.
- `permission-*` enforcement is untouched, so wildcard tokens still require explicitly enumerated permissions.

#### Negative
- Introduces mutable per-compilation state on `Compiler`, which must be reset correctly; a missed reset in a future code path could let one workflow's wildcard exemption leak into another during batch compilation.
- Validation is now coupled to generation: the step key (`id` + `client-id` + `private-key`) must stay in sync between `buildGitHubAppTokenMintStepWithMeta` and the emitted YAML, or the exemption silently stops applying and strict mode regresses.
- The generated `.lock.yml` gives no local signal that the omitted `repositories` input is intentional; readers must consult the source frontmatter.

#### Neutral
- The exemption is keyed on a 3-tuple rather than step `id` alone, which is stricter than necessary today but tolerant of future workflows that emit several app-token steps.
- The wildcard detection only triggers when `repositories` has exactly one entry equal to `"*"`; mixed lists such as `["*", "owner/repo"]` retain the existing behaviour.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
49 changes: 48 additions & 1 deletion pkg/workflow/app_token_permissions_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@ import (
"github.com/github/gh-aw/pkg/console"
)

type appTokenStepKey struct {
jobName, id, clientID, privateKey string
}

func (c *Compiler) hasGeneratedWildcardAppTokenStep(jobName string, step, with map[string]any, seen map[appTokenStepKey]bool) bool {
id, ok := step["id"].(string)
if !ok {
return false
}
clientID, ok := with["client-id"].(string)
if !ok {
return false
}
privateKey, ok := with["private-key"].(string)
if !ok {
return false
}
key := appTokenStepKey{jobName: jobName, id: id, clientID: clientID, privateKey: privateKey}
if id == "" || clientID == "" || privateKey == "" || !c.wildcardAppTokenSteps[key] || seen[key] {
return false
}
seen[key] = true
return true
}

func (c *Compiler) recordGeneratedWildcardAppTokenStep(jobName string, app *GitHubAppConfig, stepID string) {
if jobName == "" || app == nil || len(app.Repositories) != 1 {
return
}
for _, repository := range app.Repositories {
if repository != "*" {
return
}
}
if c.wildcardAppTokenSteps == nil {
c.wildcardAppTokenSteps = make(map[appTokenStepKey]bool)
}
c.wildcardAppTokenSteps[appTokenStepKey{jobName: jobName, id: stepID, clientID: app.AppID, privateKey: app.PrivateKey}] = true
}

func (c *Compiler) buildGitHubAppTokenMintStepForJob(jobName string, app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string, ownerSourceRepository string, stepName string, stepID string) []string {
steps := c.buildGitHubAppTokenMintStepWithMeta(app, permissions, fallbackRepoExpr, ownerSourceRepository, stepName, stepID)
c.recordGeneratedWildcardAppTokenStep(jobName, app, stepID)
return steps
}

func hasExplicitAppTokenPermission(with map[string]any) bool {
for key, value := range with {
if !strings.HasPrefix(strings.ToLower(key), "permission-") {
Expand Down Expand Up @@ -43,6 +89,7 @@ func (c *Compiler) validateAppTokenPermissions(workflow map[string]any, strict b
if !ok {
return nil
}
seenGeneratedWildcardSteps := make(map[appTokenStepKey]bool)
for jobName, jobValue := range jobs {
job, ok := jobValue.(map[string]any)
if !ok {
Expand All @@ -68,7 +115,7 @@ func (c *Compiler) validateAppTokenPermissions(workflow map[string]any, strict b
if !ok {
with = nil
}
if !hasExplicitAppTokenRepositories(with) {
if !hasExplicitAppTokenRepositories(with) && !c.hasGeneratedWildcardAppTokenStep(jobName, step, with, seenGeneratedWildcardSteps) {
msg := fmt.Sprintf("actions/create-github-app-token in job %q has no explicit repositories input; add repositories: ${{ github.repository }} to scope the token to the current repository", jobName)
if strict {
return fmt.Errorf("strict mode: %s", msg)
Expand Down
120 changes: 120 additions & 0 deletions pkg/workflow/app_token_permissions_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,126 @@ func TestAppTokenPermissionsCheckAllCompiledJobs(t *testing.T) {
}
}

func TestAppTokenPermissionsGeneratedWildcardRepositories(t *testing.T) {
for _, tc := range []struct {
name string
config string
stepID string
warnings int
}{
{
name: "github tool",
config: "tools:\n github:\n toolsets: [repos]\n github-app:\n app-id: ${{ vars.APP_ID }}\n private-key: ${{ secrets.APP_KEY }}\n repositories: [\"*\"]\n",
stepID: "github-mcp-app-token",
},
{
name: "safe outputs",
config: "safe-outputs:\n github-app:\n app-id: ${{ vars.APP_ID }}\n private-key: ${{ secrets.APP_KEY }}\n repositories: [\"*\"]\n create-issue:\n",
stepID: "safe-outputs-app-token",
},
{
name: "dispatch repository safe output",
config: "safe-outputs:\n github-app:\n app-id: ${{ vars.APP_ID }}\n private-key: ${{ secrets.APP_KEY }}\n repositories: [\"*\"]\n" +
" dispatch-repository:\n trigger-ci:\n workflow: ci.yml\n event_type: ci_trigger\n repository: github/gh-aw\n",
stepID: "safe-outputs-app-token",
warnings: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wildcard.md")
content := "---\non: workflow_dispatch\nstrict: true\nengine: copilot\npermissions:\n contents: read\nnetwork:\n allowed: [defaults]\n" +
tc.config + "---\n\nTest wildcard token.\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
compiler := NewCompiler()
compiler.approve = true
if err := compiler.CompileWorkflow(path); err != nil {
t.Fatalf("explicit wildcard should compile in strict mode: %v", err)
}
if compiler.warningCount != tc.warnings {
t.Fatalf("unexpected warning count: got %d, want %d", compiler.warningCount, tc.warnings)
}
lock, err := os.ReadFile(filepath.Join(dir, "wildcard.lock.yml"))
if err != nil {
t.Fatal(err)
}
step := strings.SplitN(string(lock), "id: "+tc.stepID, 2)
if len(step) != 2 {
t.Fatalf("missing generated token step %s", tc.stepID)
}
tokenInputs := strings.SplitN(step[1], "\n - name:", 2)[0]
if strings.Contains(tokenInputs, "repositories:") || !strings.Contains(tokenInputs, "permission-") {
t.Fatalf("wildcard token must omit repositories and retain explicit permissions:\n%s", tokenInputs)
}
})
}
}

func TestAppTokenPermissionsWildcardStillChecksPermissionsAndOtherSteps(t *testing.T) {
compiler := NewCompiler()
compiler.buildGitHubAppTokenMintStepForJob(
"agent",
&GitHubAppConfig{AppID: "app-id", PrivateKey: "private-key", Repositories: []string{"*"}},
nil, "", "", "Mint token", "generated-token",
)
generated := map[string]any{
"id": "generated-token", "uses": "actions/create-github-app-token@sha",
"with": map[string]any{"client-id": "app-id", "private-key": "private-key"},
}
workflow := map[string]any{"jobs": map[string]any{"agent": map[string]any{"steps": []any{generated}}}}
if err := compiler.validateAppTokenPermissions(workflow, true); err == nil || !strings.Contains(err.Error(), "permission-*") {
t.Fatalf("wildcard must not bypass permission checks, got %v", err)
}
generated["with"].(map[string]any)["permission-contents"] = "read"
if err := compiler.validateAppTokenPermissions(workflow, true); err != nil {
t.Fatalf("generated wildcard should pass with explicit permissions: %v", err)
}
workflow["jobs"].(map[string]any)["agent"].(map[string]any)["steps"] = append(
workflow["jobs"].(map[string]any)["agent"].(map[string]any)["steps"].([]any),
map[string]any{
"id": "generated-token", "uses": "actions/create-github-app-token@sha",
"with": map[string]any{
"client-id": "app-id", "private-key": "private-key", "permission-contents": "read",
},
},
)
if err := compiler.validateAppTokenPermissions(workflow, true); err == nil || !strings.Contains(err.Error(), "repositories input") {
t.Fatalf("duplicate matching step in the generated job must still require repository scoping, got %v", err)
}
workflow["jobs"].(map[string]any)["custom"] = map[string]any{"steps": []any{
map[string]any{
"id": "generated-token", "uses": "actions/create-github-app-token@sha",
"with": map[string]any{
"client-id": "app-id", "private-key": "private-key", "permission-contents": "read",
},
},
}}
if err := compiler.validateAppTokenPermissions(workflow, true); err == nil || !strings.Contains(err.Error(), "repositories input") {
t.Fatalf("matching steps in another job must still require repository scoping, got %v", err)
}
}

func TestAppTokenPermissionsUntrackedWildcardHelperDoesNotExemptSteps(t *testing.T) {
compiler := NewCompiler()
compiler.buildGitHubAppTokenMintStepWithMeta(
&GitHubAppConfig{AppID: "app-id", PrivateKey: "private-key", Repositories: []string{"*"}},
nil, "", "", "Mint token", "generated-token",
)
workflow := map[string]any{"jobs": map[string]any{"agent": map[string]any{"steps": []any{
map[string]any{
"id": "generated-token", "uses": "actions/create-github-app-token@sha",
"with": map[string]any{
"client-id": "app-id", "private-key": "private-key", "permission-contents": "read",
},
},
}}}}
if err := compiler.validateAppTokenPermissions(workflow, true); err == nil || !strings.Contains(err.Error(), "repositories input") {
t.Fatalf("building an untracked YAML fragment must not exempt a step, got %v", err)
}
}

func TestAppTokenPermissionsGeneratedPreActivationStep(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "app-token.md")
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/cache_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection
// Cache job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace
cacheTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName)
cacheParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName)
setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID)...)
setupSteps = append(setupSteps, c.generateSetupStepForJob("update_cache_memory", data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID, "")...)
}

// Prepend setup steps to all cache steps
Expand Down
6 changes: 4 additions & 2 deletions pkg/workflow/checkout_step_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission
checkoutManagerLog.Printf("Generating app token minting step for checkout index=%d repo=%q", checkoutIndex, entry.key.repository)
// Pass empty fallback so the app token defaults to github.event.repository.name.
// Checkout-specific cross-repo scoping is handled via the explicit repository field.
steps = append(steps, collapseYAMLLinesIntoSteps(c.buildGitHubAppTokenMintStepWithMeta(
steps = append(steps, collapseYAMLLinesIntoSteps(c.buildGitHubAppTokenMintStepForJob(
"agent",
entry.githubApp,
permissions,
"",
Expand All @@ -76,7 +77,8 @@ func (cm *CheckoutManager) GenerateSafeOutputCheckoutAppTokenSteps(c *Compiler,
continue
}
checkoutManagerLog.Printf("Generating safe_outputs app token minting step for checkout index=%d repo=%q", checkoutIndex, entry.key.repository)
steps = append(steps, collapseYAMLLinesIntoSteps(c.buildGitHubAppTokenMintStepWithMeta(
steps = append(steps, collapseYAMLLinesIntoSteps(c.buildGitHubAppTokenMintStepForJob(
"safe_outputs",
entry.safeOutputApp,
permissions,
"",
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath

// Reset the step order tracker for this compilation
c.stepOrderTracker = NewStepOrderTracker()
c.wildcardAppTokenSteps = nil

// Reset schedule friendly formats for this compilation
c.scheduleFriendlyFormats = nil
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/compiler_activation_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ func (c *Compiler) addActivationSetupAndWorkflowCallSteps(ctx *activationJobBuil
if enableArtifactClient {
artifactClientCondition = maxDailyAICreditsConfiguredIfExpr
}
ctx.steps = append(ctx.steps, c.generateSetupStepWithArtifactClientCondition(
ctx.steps = append(ctx.steps, c.generateSetupStepForJob(
"activation",
ctx.data,
setupActionRef,
SetupActionDestination,
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/compiler_activation_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ func (c *Compiler) resolveFrontmatterSkillToken(ctx *activationJobBuildContext,
return tokenExpr
}
stepID := fmt.Sprintf("frontmatter-skill-app-token-%d", stepNumber)
ctx.steps = append(ctx.steps, c.buildGitHubAppTokenMintStepWithMeta(
ctx.steps = append(ctx.steps, c.buildGitHubAppTokenMintStepForJob(
"activation",
skillRef.GitHubApp,
nil,
"",
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_custom_job_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (c *Compiler) buildRestoreMemorySteps(cfg *restoreMemoryConfig, jobName str
}
setupLines = append(setupLines, c.generateCheckoutActionsFolder(data)...)
// Pass empty trace IDs — custom jobs do not inherit the activation span.
setupLines = append(setupLines, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...)
setupLines = append(setupLines, c.generateSetupStepForJob(jobName, data, setupActionRef, SetupActionDestination, false, "", "", "")...)
}

if cfg.CacheMemory {
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ func (c *Compiler) buildPushExperimentsStateSetupSteps(data *WorkflowData) []str
traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName)
parentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName)
steps := c.generateCheckoutActionsFolder(data)
return append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID)...)
return append(steps, c.generateSetupStepForJob(pushExperimentsStateJobName, data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID, "")...)
}

func buildPushExperimentsStateCheckoutStep() string {
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/compiler_github_mcp_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingSteps(data *WorkflowData) []s
}

// Generate the token minting step using the existing helper from safe_outputs_app.go
rawSteps := c.buildGitHubAppTokenMintStepWithMeta(
rawSteps := c.buildGitHubAppTokenMintStepForJob(
"agent",
app,
permissions,
"",
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
agentTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName)
agentParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName)
steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID)...)
steps = append(steps, c.generateSetupStepForJob("agent", data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID, "")...)
}
// Set runtime paths that depend on RUNNER_TEMP via $GITHUB_ENV.
// These cannot be set in job-level env: because the runner context is not
Expand Down
5 changes: 3 additions & 2 deletions pkg/workflow/compiler_pre_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (c *Compiler) buildPreActivationPermissions(data *WorkflowData, setupAction

// Pre-activation job doesn't need project support (no safe outputs processed here).
// Pre-activation generates the root trace ID; activation will reuse it via setup-trace-id output.
steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...)
steps = append(steps, c.generateSetupStepForJob("pre_activation", data, setupActionRef, SetupActionDestination, false, "", "", "")...)

var perms *Permissions
if needsContentsRead {
Expand Down Expand Up @@ -778,7 +778,8 @@ func (c *Compiler) buildPreActivationAppTokenMintStep(app *GitHubAppConfig) []st
PermissionIssues: PermissionRead,
PermissionPullRequests: PermissionRead,
})
return c.buildGitHubAppTokenMintStepWithMeta(
return c.buildGitHubAppTokenMintStepForJob(
"pre_activation",
app,
permissions,
"",
Expand Down
Loading
Loading