Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Allow explicit wildcard GitHub App repositories in strict mode
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 6, 2026
commit 3803aa59b6665f30a7ae5753f24086e2d82598c3
23 changes: 22 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,27 @@ import (
"github.com/github/gh-aw/pkg/console"
)

type appTokenStepKey struct {
id, clientID, privateKey string
}

func (c *Compiler) hasGeneratedWildcardAppTokenStep(step, with map[string]any) 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
}
return id != "" && clientID != "" && privateKey != "" &&
c.wildcardAppTokenSteps[appTokenStepKey{id, clientID, privateKey}]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bac8c5f: wildcard exemptions are now scoped to the containing job and matched step metadata, with only one matching step allowed to consume the exemption. Regression tests cover same-job and cross-job spoofing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: the wildcard exemption can be spoofed by an unrelated, custom step.

hasGeneratedWildcardAppTokenStep matches purely on step["id"], with["client-id"], and with["private-key"] — all of which are attacker/author-controlled strings in the compiled workflow, not a reference to the actual generated step object. Any hand-written job (e.g. one added via an import, or directly in the markdown's custom steps:) that reuses the same id: github-mcp-app-token (or safe-outputs-app-token) together with the same client-id/private-key expressions will be misclassified as compiler-generated and skip the repositories requirement in strict mode — even though it has no connection to the actual wildcard-configured GitHub App step.

This defeats the stated safeguard: "Continue requiring explicit repositories input... for unrelated token steps" (see PR description). Since client-id/private-key are typically ${{ vars.APP_ID }} / ${{ secrets.APP_KEY }} — values an author of the workflow already knows/controls — this is trivially copyable, not a secret binding.

Suggested fix: track provenance via an identity that user-authored steps can't fabricate, e.g.:

  • Record the (jobName, step index) or a pointer/marker written into the step map at generation time (e.g. an internal field not emitted to YAML, consulted before stripping), rather than matching on step content, or
  • Validate/tag compiler-generated steps before they are merged with custom/imported steps, so the exemption is based on "this step object came from the generator" rather than "this step's visible fields happen to match."

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bac8c5f: custom steps reusing the generated ID and credential expressions no longer inherit the exemption from another job; duplicate matches in the generated job are also rejected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The existing Copilot review comment on this line is correct and worth confirming: appTokenStepKey{id, clientID, privateKey}} is keyed only on step id, which is a job-local identifier, but wildcardAppTokenSteps is a single compiler-wide map built across all jobs in the workflow. Any actions/create-github-app-token step in an unrelated custom job (steps:, jobs:) that happens to reuse the same id (e.g. github-mcp-app-token) plus the same client-id/private-key expressions would be silently exempted from the repositories/permission scoping check, even if its own repositories input is missing or broader than intended.

💡 Suggested fix

Scope the key to the job as well, e.g. appTokenStepKey{jobName, id, clientID, privateKey}, and pass jobName down from validateAppTokenPermissions into hasGeneratedWildcardAppTokenStep. This keeps the exemption tied to exactly the compiler-generated step it was computed for, closing the collision window a user-authored step could otherwise exploit to bypass strict-mode scoping.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bac8c5f: the provenance key now includes the containing job, and validation only exempts one matching step. Added tests for cross-job and same-job spoofing.

}

func hasExplicitAppTokenPermission(with map[string]any) bool {
for key, value := range with {
if !strings.HasPrefix(strings.ToLower(key), "permission-") {
Expand Down Expand Up @@ -68,7 +89,7 @@ func (c *Compiler) validateAppTokenPermissions(workflow map[string]any, strict b
if !ok {
with = nil
}
if !hasExplicitAppTokenRepositories(with) {
if !hasExplicitAppTokenRepositories(with) && !c.hasGeneratedWildcardAppTokenStep(step, with) {
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
77 changes: 77 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,83 @@ func TestAppTokenPermissionsCheckAllCompiledJobs(t *testing.T) {
}
}

func TestAppTokenPermissionsGeneratedWildcardRepositories(t *testing.T) {
for _, tc := range []struct {
name string
config string
stepID string
}{
{
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",
},
} {
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 != 0 {
t.Fatalf("explicit wildcard should not produce scope warnings, got %d", compiler.warningCount)
}
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.buildGitHubAppTokenMintStepWithMeta(
&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)["custom"] = map[string]any{"steps": []any{
map[string]any{"uses": "actions/create-github-app-token@sha", "with": map[string]any{
"client-id": "other-app", "private-key": "private-key", "permission-contents": "read",
}},
}}
if err := compiler.validateAppTokenPermissions(workflow, true); err == nil || !strings.Contains(err.Error(), "repositories input") {
t.Fatalf("other steps must still require repository scoping, got %v", err)
}
}

func TestAppTokenPermissionsGeneratedPreActivationStep(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "app-token.md")
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
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 // Compiler-generated token steps with an explicit repositories: ["*"] configuration
// 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
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/safe_outputs_app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,16 @@ func appendGitHubAppTokenRepositoryInput(steps []string, app *GitHubAppConfig, f

func (c *Compiler) buildGitHubAppTokenMintStepWithMeta(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string, ownerSourceRepository string, stepName string, stepID string) []string {
safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories))
if len(app.Repositories) == 1 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Side effect (c.wildcardAppTokenSteps[...] = true) is recorded unconditionally every time buildGitHubAppTokenMintStepWithMeta runs with repositories: ["*"], even for call sites where the generated step is never subsequently fed through validateAppTokenPermissions on the same parsedWorkflow pass (e.g. helper call sites used only to produce YAML fragments for embedding elsewhere). The tests added cover the two documented call sites (github tool, safe-outputs) but not the ~10 other callers of this function (steering issue, checkout, plugin installation, dispatch-repository, etc.) that also pass app.Repositories == ["*"].

💡 Suggested test

Add a case (or a table-driven loop) exercising at least one more caller path — e.g. tools.checkout[*].github-app or safe-outputs.dispatch-repository — with repositories: ["*"] in strict mode, to confirm the exemption generalizes correctly rather than only working for the two paths explicitly tested.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bac8c5f: the YAML-fragment builder is side-effect free; wildcard provenance is recorded at job-emission points. Strict-mode coverage now also exercises dispatch-repository safe outputs.

for _, repository := range app.Repositories {
if repository == "*" {
if c.wildcardAppTokenSteps == nil {
c.wildcardAppTokenSteps = make(map[appTokenStepKey]bool)
}
c.wildcardAppTokenSteps[appTokenStepKey{stepID, app.AppID, app.PrivateKey}] = true
}
}
}
var steps []string

owner, ownerSteps := resolveGitHubAppOwner(app, ownerSourceRepository, stepName, stepID)
Expand Down
Loading