diff --git a/docs/adr/66039-trust-compiler-generated-wildcard-app-token-steps.md b/docs/adr/66039-trust-compiler-generated-wildcard-app-token-steps.md new file mode 100644 index 00000000000..a8b6acf21a7 --- /dev/null +++ b/docs/adr/66039-trust-compiler-generated-wildcard-app-token-steps.md @@ -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.* diff --git a/pkg/workflow/app_token_permissions_validation.go b/pkg/workflow/app_token_permissions_validation.go index caa6aed85ed..22c88ac1c8f 100644 --- a/pkg/workflow/app_token_permissions_validation.go +++ b/pkg/workflow/app_token_permissions_validation.go @@ -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-") { @@ -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 { @@ -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) diff --git a/pkg/workflow/app_token_permissions_validation_test.go b/pkg/workflow/app_token_permissions_validation_test.go index 4ce9a66eeae..6978db74e69 100644 --- a/pkg/workflow/app_token_permissions_validation_test.go +++ b/pkg/workflow/app_token_permissions_validation_test.go @@ -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") diff --git a/pkg/workflow/cache_memory.go b/pkg/workflow/cache_memory.go index 57ab6b03c68..e036b131f0d 100644 --- a/pkg/workflow/cache_memory.go +++ b/pkg/workflow/cache_memory.go @@ -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 diff --git a/pkg/workflow/checkout_step_generator.go b/pkg/workflow/checkout_step_generator.go index 20e41184ab5..d484f4b58b8 100644 --- a/pkg/workflow/checkout_step_generator.go +++ b/pkg/workflow/checkout_step_generator.go @@ -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, "", @@ -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, "", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 009ea2df8db..9cdbee476cd 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -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 diff --git a/pkg/workflow/compiler_activation_context.go b/pkg/workflow/compiler_activation_context.go index ab0fca29fba..aaf31f7cb25 100644 --- a/pkg/workflow/compiler_activation_context.go +++ b/pkg/workflow/compiler_activation_context.go @@ -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, diff --git a/pkg/workflow/compiler_activation_steps.go b/pkg/workflow/compiler_activation_steps.go index ddfab79eba9..c4525bd4bf7 100644 --- a/pkg/workflow/compiler_activation_steps.go +++ b/pkg/workflow/compiler_activation_steps.go @@ -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, "", diff --git a/pkg/workflow/compiler_custom_job_memory.go b/pkg/workflow/compiler_custom_job_memory.go index 938c2b0e825..c96d56cc8e4 100644 --- a/pkg/workflow/compiler_custom_job_memory.go +++ b/pkg/workflow/compiler_custom_job_memory.go @@ -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 { diff --git a/pkg/workflow/compiler_experiments.go b/pkg/workflow/compiler_experiments.go index 4b8264e54cf..54120a0ca12 100644 --- a/pkg/workflow/compiler_experiments.go +++ b/pkg/workflow/compiler_experiments.go @@ -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 { diff --git a/pkg/workflow/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go index 15d749994d3..ce486165be5 100644 --- a/pkg/workflow/compiler_github_mcp_steps.go +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -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, "", diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index 0daf8472675..2146396fe40 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -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 diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 1beb3150665..6edaf2ead2b 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -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 { @@ -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, "", diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 855353e0d2c..c1d6f039e59 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -228,7 +228,7 @@ func (c *Compiler) buildSafeOutputsSetupSteps(data *WorkflowData) []string { // Safe outputs job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace safeOutputsTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) safeOutputsParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("safe_outputs", data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID, "")...) } // Mask OTLP telemetry headers immediately after setup so authentication tokens cannot @@ -756,6 +756,7 @@ func (c *Compiler) buildPreambleTokenSteps(data *WorkflowData, outputs map[strin appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepForRepository( + "safe_outputs", data.SafeOutputs.GitHubApp, appPermissions, appTokenFallbackRepo, @@ -765,7 +766,8 @@ func (c *Compiler) buildPreambleTokenSteps(data *WorkflowData, outputs map[strin } if headApp := getSafeOutputsHeadApp(data.SafeOutputs); headApp != nil { headRepoSlug := getSafeOutputsHeadRepoSlug(data.SafeOutputs) - preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepWithMeta( + preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepForJob( + "safe_outputs", headApp, NewPermissionsContentsWritePRWrite(), headRepoNameFromSlug(headRepoSlug), diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 92d1de560e4..95dce1b633b 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -182,7 +182,8 @@ func (c *Compiler) addAppTokenMintingSteps(data *WorkflowData) []string { } stepID := handler.Key + "-app-token" consolidatedSafeOutputsStepsLog.Printf("Adding per-handler GitHub App token minting step for %s", handler.Key) - steps = append(steps, c.buildGitHubAppTokenMintStepWithMeta( + steps = append(steps, c.buildGitHubAppTokenMintStepForJob( + "safe_outputs", handlerApp, handlerPermissions, "", @@ -193,7 +194,8 @@ func (c *Compiler) addAppTokenMintingSteps(data *WorkflowData) []string { } if commentMemory := data.CommentMemoryConfig; commentMemory != nil && commentMemory.GitHubApp != nil && !isHandlerStaged(templatableBoolIsTrue(data.SafeOutputs.Staged), commentMemory.Staged) { - steps = append(steps, c.buildGitHubAppTokenMintStepWithMeta( + steps = append(steps, c.buildGitHubAppTokenMintStepForJob( + "safe_outputs", commentMemory.GitHubApp, NewPermissionsIssuesWrite(), "", @@ -218,7 +220,8 @@ func (c *Compiler) addAppTokenMintingSteps(data *WorkflowData) []string { } stepID := dispatchRepositoryToolAppTokenStepID(toolKey) consolidatedSafeOutputsStepsLog.Printf("Adding dispatch-repository GitHub App token minting step for %s", toolKey) - steps = append(steps, c.buildGitHubAppTokenMintStepWithMeta( + steps = append(steps, c.buildGitHubAppTokenMintStepForJob( + "safe_outputs", tool.GitHubApp, NewPermissionsContentsWrite(), "", diff --git a/pkg/workflow/compiler_steering_issue.go b/pkg/workflow/compiler_steering_issue.go index 519b6b4c37c..a47e37699e9 100644 --- a/pkg/workflow/compiler_steering_issue.go +++ b/pkg/workflow/compiler_steering_issue.go @@ -26,7 +26,7 @@ func steeringIssueFallbackToken(data *WorkflowData) string { return resolveSafeOutputGitHubToken(data.SafeOutputs.GitHubToken) } -func (c *Compiler) buildSteeringIssueTokenSteps(data *WorkflowData, app *GitHubAppConfig, permissions *Permissions, stepName string, stepID string) ([]string, string) { +func (c *Compiler) buildSteeringIssueTokenSteps(data *WorkflowData, jobName string, app *GitHubAppConfig, permissions *Permissions, stepName string, stepID string) ([]string, string) { token := steeringIssueFallbackToken(data) if app == nil { return nil, token @@ -34,7 +34,7 @@ func (c *Compiler) buildSteeringIssueTokenSteps(data *WorkflowData, app *GitHubA var steps []string if stepName != "" { - steps = c.buildGitHubAppTokenMintStepWithMeta(app, permissions, "", "", stepName, stepID) + steps = c.buildGitHubAppTokenMintStepForJob(jobName, app, permissions, "", "", stepName, stepID) } appToken := fmt.Sprintf("${{ steps.%s.outputs.token }}", stepID) if app.shouldIgnoreMissingKey() { @@ -51,6 +51,7 @@ func (c *Compiler) addActivationSteeringIssueStep(ctx *activationJobBuildContext permissions := NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionIssues: PermissionWrite}) tokenSteps, token := c.buildSteeringIssueTokenSteps( ctx.data, + "activation", steeringIssueApp(ctx.data), permissions, "Generate GitHub App token (create steering issue)", @@ -81,7 +82,7 @@ func (c *Compiler) buildConclusionSteeringIssueTokenSteps(data *WorkflowData) ([ app := steeringIssueApp(data) stepID := "safe-outputs-app-token" stepName := "" - return c.buildSteeringIssueTokenSteps(data, app, NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionIssues: PermissionWrite}), stepName, stepID) + return c.buildSteeringIssueTokenSteps(data, "conclusion", app, NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionIssues: PermissionWrite}), stepName, stepID) } func (c *Compiler) buildConclusionSteeringIssueStep(data *WorkflowData, mainJobName, token string) []string { diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 6c7a00b6366..41dd7349d8d 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -76,6 +76,7 @@ type Compiler struct { featureUsage map[string]int // Counts experimental feature usage across workflows in batch mode permissionWarningShown map[string]string // Tracks markdown paths and last warning fingerprint (frontmatter hash when available, otherwise formatted warning text) allowedDomainsCache map[string]allowedDomain // Cached allowed-domains per markdown path with the frontmatter hash that produced it + wildcardAppTokenSteps map[appTokenStepKey]bool // Job-scoped compiler-generated token steps with explicit repositories: ["*"]. // modelPricingResolver is an optional callback for resolving per-token pricing of models that // are absent from the embedded models.json catalog. When non-nil it is called during // buildInitialWorkflowData for the workflow's configured model; any returned pricing is merged diff --git a/pkg/workflow/compiler_unlock_job.go b/pkg/workflow/compiler_unlock_job.go index 6c91dbffe72..dc764f7d624 100644 --- a/pkg/workflow/compiler_unlock_job.go +++ b/pkg/workflow/compiler_unlock_job.go @@ -40,7 +40,7 @@ func (c *Compiler) buildUnlockJob(data *WorkflowData, threatDetectionEnabled boo // Unlock job depends on activation, reuse its trace ID unlockTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) unlockParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("unlock", data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID, "")...) // Add unlock step // Build condition: only unlock if issue was locked by activation job diff --git a/pkg/workflow/compiler_yaml_ai_execution.go b/pkg/workflow/compiler_yaml_ai_execution.go index cc17866b57e..41a067d4d86 100644 --- a/pkg/workflow/compiler_yaml_ai_execution.go +++ b/pkg/workflow/compiler_yaml_ai_execution.go @@ -459,6 +459,18 @@ func (c *Compiler) generateEngineInstallAndPreAgentSteps(yaml *strings.Builder, } pluginInstallSteps := pluginInstaller.GetPluginInstallationSteps(data) + for i, ref := range data.PluginReferences { + if ref.GitHubApp == nil { + continue + } + stepID := pluginAppTokenStepID(i) + for _, step := range pluginInstallSteps { + if strings.Contains(strings.Join(step, "\n"), "id: "+stepID+"\n") { + c.recordGeneratedWildcardAppTokenStep("agent", ref.GitHubApp, stepID) + break + } + } + } compilerYamlLog.Printf("Adding %d plugin installation steps for %s", len(pluginInstallSteps), engine.GetID()) for _, step := range pluginInstallSteps { for _, line := range step { diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index 1e9c0100c97..9c0abbb36de 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -218,6 +218,14 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, return c.generateSetupStepWithArtifactClientCondition(data, setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, "") } +func (c *Compiler) generateSetupStepForJob(jobName string, data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string, artifactClientCondition string) []string { + steps := c.generateSetupStepWithArtifactClientCondition(data, setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, artifactClientCondition) + if data != nil && strings.Contains(strings.Join(steps, ""), "id: "+otlpOIDCMintStepID+"\n") { + c.recordGeneratedWildcardAppTokenStep(jobName, getOTLPGitHubAppTokenConfig(data.RawFrontmatter), otlpOIDCMintStepID) + } + return steps +} + func (c *Compiler) generateSetupStepWithArtifactClientCondition(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string, artifactClientCondition string) []string { //nolint:largefunc // Existing setup-step emission is intentionally centralized. lines := c.generateOTLPOIDCMintStep(data) hasOTLPOIDC := len(lines) > 0 diff --git a/pkg/workflow/drive_memory.go b/pkg/workflow/drive_memory.go index c24646af832..06dbdc925db 100644 --- a/pkg/workflow/drive_memory.go +++ b/pkg/workflow/drive_memory.go @@ -215,7 +215,7 @@ func (c *Compiler) buildUpdateDriveMemoryJob(data *WorkflowData, threatDetection if setupActionRef != "" || c.actionMode.IsScript() { steps = append(steps, c.generateCheckoutActionsFolder(data)...) traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, setupParentSpanNeedsExpr(constants.ActivationJobName))...) + steps = append(steps, c.generateSetupStepForJob("update_drive_memory", data, setupActionRef, SetupActionDestination, false, traceID, setupParentSpanNeedsExpr(constants.ActivationJobName), "")...) } hasWritableDrive := false diff --git a/pkg/workflow/evals_job.go b/pkg/workflow/evals_job.go index 77a0aa3df75..fa99eb1e5be 100644 --- a/pkg/workflow/evals_job.go +++ b/pkg/workflow/evals_job.go @@ -38,7 +38,7 @@ func (c *Compiler) buildEvalsJob(data *WorkflowData) (*Job, error) { // Reuse the activation job trace ID so all jobs share one OTLP trace. evalsTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) evalsParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, evalsTraceID, evalsParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("evals", data, setupActionRef, SetupActionDestination, false, evalsTraceID, evalsParentSpanID, "")...) } // Download agent output artifact to access output files (prompt.txt, agent_output.json). @@ -126,7 +126,7 @@ func (c *Compiler) buildPushEvalsStateJob(data *WorkflowData) (*Job, error) { steps = append(steps, c.generateCheckoutActionsFolder(data)...) traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) parentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("push_evals_state", data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID, "")...) } steps = append(steps, diff --git a/pkg/workflow/ledger_job.go b/pkg/workflow/ledger_job.go index 967a4b28c91..53b74abd355 100644 --- a/pkg/workflow/ledger_job.go +++ b/pkg/workflow/ledger_job.go @@ -16,7 +16,7 @@ func (c *Compiler) buildPushLedgerChangesJob(data *WorkflowData, threatDetection needs = append(needs, string(constants.DetectionJobName)) } steps := append([]string{}, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, c.generateSetupStep(data, c.resolveActionReference("./actions/setup", data), SetupActionDestination, false, "", "")...) + steps = append(steps, c.generateSetupStepForJob("push_ledger_changes", data, c.resolveActionReference("./actions/setup", data), SetupActionDestination, false, "", "", "")...) steps = append(steps, " - name: Download validated ledger transactions\n", " if: always()\n", diff --git a/pkg/workflow/notify_comment_conclusion_helpers.go b/pkg/workflow/notify_comment_conclusion_helpers.go index 3c99ea3a876..ed9334d7016 100644 --- a/pkg/workflow/notify_comment_conclusion_helpers.go +++ b/pkg/workflow/notify_comment_conclusion_helpers.go @@ -28,7 +28,7 @@ func (c *Compiler) buildConclusionSetupSteps(data *WorkflowData) []string { // Conclusion/notify job depends on activation, reuse its trace ID notifyTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) notifyParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("conclusion", data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID, "")...) } // Add GitHub App token minting step if app is configured @@ -39,6 +39,7 @@ func (c *Compiler) buildConclusionSetupSteps(data *WorkflowData) []string { appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } steps = append(steps, c.buildGitHubAppTokenMintStepForRepository( + "conclusion", data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo, diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index bd4a2b3d9f3..4f0baaaafa5 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -139,7 +139,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, // Publish assets job depends on the agent job; reuse its trace ID so all jobs share one OTLP trace publishTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) publishParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID)...) + preSteps = append(preSteps, c.generateSetupStepForJob("upload_assets", data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID, "")...) } // Step 1: Checkout repository diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index c538502b421..89b07356c3b 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -675,7 +675,7 @@ func (c *Compiler) buildPushRepoMemorySetupAndCheckoutSteps(data *WorkflowData, steps = append(steps, c.generateCheckoutActionsFolder(data)...) repoMemoryTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) repoMemoryParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("push_repo_memory", data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID, "")...) } var checkoutStep strings.Builder checkoutStep.WriteString(" - name: Checkout repository\n") diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index f8c1b8658b6..ad2574479c0 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -375,8 +375,8 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions return c.buildGitHubAppTokenMintStepWithMeta(app, permissions, fallbackRepoExpr, "", "Generate GitHub App token", "safe-outputs-app-token") } -func (c *Compiler) buildGitHubAppTokenMintStepForRepository(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string, ownerSourceRepository string) []string { - return c.buildGitHubAppTokenMintStepWithMeta(app, permissions, fallbackRepoExpr, ownerSourceRepository, "Generate GitHub App token", "safe-outputs-app-token") +func (c *Compiler) buildGitHubAppTokenMintStepForRepository(jobName string, app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string, ownerSourceRepository string) []string { + return c.buildGitHubAppTokenMintStepForJob(jobName, app, permissions, fallbackRepoExpr, ownerSourceRepository, "Generate GitHub App token", "safe-outputs-app-token") } func appTokenPermissionFields(app *GitHubAppConfig, permissions *Permissions) map[string]string { diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go index 7762cd7c123..5a83cd74988 100644 --- a/pkg/workflow/safe_outputs_jobs.go +++ b/pkg/workflow/safe_outputs_jobs.go @@ -71,6 +71,7 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } steps = append(steps, c.buildGitHubAppTokenMintStepForRepository( + config.JobName, data.SafeOutputs.GitHubApp, config.Permissions, appTokenFallbackRepo, diff --git a/pkg/workflow/threat_detection_job.go b/pkg/workflow/threat_detection_job.go index 9e986567bb8..afc487ca8f4 100644 --- a/pkg/workflow/threat_detection_job.go +++ b/pkg/workflow/threat_detection_job.go @@ -42,7 +42,7 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // Detection job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace detectionTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) detectionParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID)...) + steps = append(steps, c.generateSetupStepForJob("detection", data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID, "")...) } // Download the activation artifact first because it is the durable source of the