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
Add integration tests and update security documentation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan committed Oct 18, 2025
commit 09750db3ec01dfe57d9aa9e62aff5b29fef2b8b6
59 changes: 42 additions & 17 deletions docs/src/content/docs/guides/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,22 +237,56 @@ GitHub Agentic Workflows support flexible token configuration for different exec

#### GitHub Token Precedence

By default, workflows use GitHub's standard `GITHUB_TOKEN` for authentication. However, you can override this behavior using environment variables with the following precedence:
Workflows use a hierarchical token precedence system that allows you to configure authentication tokens at different levels. The precedence order from highest to lowest is:

1. **`GH_AW_GITHUB_TOKEN`** - Primary override token (highest priority)
2. **`GITHUB_TOKEN`** - Standard GitHub Actions token (fallback)
1. **Individual safe-output `github-token`** - Token specified for a specific safe-output (e.g., `create-issue.github-token`)
2. **Safe-outputs global `github-token`** - Token specified at the `safe-outputs` level
3. **Top-level `github-token`** - Token specified in workflow frontmatter (new)
4. **Default fallback** - `${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}`

This allows you to set a default token for the entire workflow while still allowing specific safe-outputs to use different tokens when needed.

#### Token Configuration Examples

**Basic override for enhanced permissions:**
**Top-level token for entire workflow:**

```yaml
# Set via repository secrets
env:
GH_AW_GITHUB_TOKEN: ${{ secrets.CUSTOM_PAT }}
---
on: push
github-token: ${{ secrets.CUSTOM_PAT }}
engine: claude
tools:
github:
allowed: [list_issues]
---
```

This token will be used for:
- Engine authentication (GitHub MCP server, etc.)
- Checkout steps (in trial mode)
- Safe-output operations (unless overridden)

**Safe outputs with custom tokens:**

```yaml
---
on: push
github-token: ${{ secrets.DEFAULT_PAT }}
safe-outputs:
github-token: ${{ secrets.SAFE_OUTPUT_PAT }}
create-issue:
github-token: ${{ secrets.ISSUE_SPECIFIC_PAT }}
create-pull-request:
# Uses safe-outputs global token
---
```

**Per-job token configuration:**
In this example:
- `create-issue` uses `ISSUE_SPECIFIC_PAT` (highest precedence)
- `create-pull-request` uses `SAFE_OUTPUT_PAT` (safe-outputs level)
- Engine and other operations use `DEFAULT_PAT` (top-level)

**Per-job token configuration (legacy):**

```yaml
jobs:
Expand All @@ -264,15 +298,6 @@ jobs:
# Workflow steps use the enhanced token
```

**Safe outputs with custom tokens:**

```yaml
safe-outputs:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
create-issue:
create-pull-request:
```

#### Security Considerations

When using custom tokens:
Expand Down
267 changes: 267 additions & 0 deletions pkg/workflow/github_token_precedence_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
//go:build integration

package workflow

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestTopLevelGitHubTokenPrecedence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "github-token-precedence-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)

t.Run("top-level github-token used when no safe-outputs token", func(t *testing.T) {
testContent := `---
name: Test Top-Level GitHub Token
on:
issues:
types: [opened]
engine: claude
github-token: ${{ secrets.TOPLEVEL_PAT }}
tools:
github:
mode: remote
allowed: [list_issues]
---

# Test Top-Level GitHub Token

Test that top-level github-token is used in engine configuration.
`

testFile := filepath.Join(tmpDir, "test-toplevel-token.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler(false, "", "test")
err := compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow: %v", err)
}

outputFile := filepath.Join(tmpDir, "test-toplevel-token.lock.yml")
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}

yamlContent := string(content)

// Verify that the top-level token is used in the GitHub MCP config
if !strings.Contains(yamlContent, "Bearer ${{ secrets.TOPLEVEL_PAT }}") {
t.Error("Expected top-level github-token to be used in Authorization header")
t.Logf("Generated YAML:\n%s", yamlContent)
}
})

t.Run("safe-outputs github-token overrides top-level", func(t *testing.T) {
testContent := `---
name: Test Safe-Outputs Override
on:
issues:
types: [opened]
engine: claude
github-token: ${{ secrets.TOPLEVEL_PAT }}
safe-outputs:
github-token: ${{ secrets.SAFE_OUTPUTS_PAT }}
create-issue:
---

# Test Safe-Outputs Override

Test that safe-outputs github-token overrides top-level.
`

testFile := filepath.Join(tmpDir, "test-safe-outputs-override.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler(false, "", "test")
err := compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow: %v", err)
}

outputFile := filepath.Join(tmpDir, "test-safe-outputs-override.lock.yml")
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}

yamlContent := string(content)

// Verify that safe-outputs token is used in the create_issue job
if !strings.Contains(yamlContent, "github-token: ${{ secrets.SAFE_OUTPUTS_PAT }}") {
t.Error("Expected safe-outputs github-token to be used in create_issue job")
t.Logf("Generated YAML:\n%s", yamlContent)
}

// Verify that top-level token is NOT used in safe-outputs job
if strings.Contains(yamlContent, "github-token: ${{ secrets.TOPLEVEL_PAT }}") {
t.Error("Top-level github-token should not be used when safe-outputs token is present")
}
})

t.Run("individual safe-output token overrides both", func(t *testing.T) {
testContent := `---
name: Test Individual Override
on:
issues:
types: [opened]
engine: claude
github-token: ${{ secrets.TOPLEVEL_PAT }}
safe-outputs:
github-token: ${{ secrets.SAFE_OUTPUTS_PAT }}
create-issue:
github-token: ${{ secrets.INDIVIDUAL_PAT }}
---

# Test Individual Override

Test that individual safe-output github-token has highest precedence.
`

testFile := filepath.Join(tmpDir, "test-individual-override.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler(false, "", "test")
err := compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow: %v", err)
}

outputFile := filepath.Join(tmpDir, "test-individual-override.lock.yml")
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}

yamlContent := string(content)

// Verify that individual token is used in the create_issue job
if !strings.Contains(yamlContent, "github-token: ${{ secrets.INDIVIDUAL_PAT }}") {
t.Error("Expected individual safe-output github-token to be used in create_issue job")
t.Logf("Generated YAML:\n%s", yamlContent)
}

// Count occurrences of each token to verify precedence
individualCount := strings.Count(yamlContent, "github-token: ${{ secrets.INDIVIDUAL_PAT }}")
safeOutputsCount := strings.Count(yamlContent, "github-token: ${{ secrets.SAFE_OUTPUTS_PAT }}")
toplevelCount := strings.Count(yamlContent, "github-token: ${{ secrets.TOPLEVEL_PAT }}")

if individualCount == 0 {
t.Error("Individual token should be present at least once")
}

// Note: safe-outputs global token might appear in other safe-output jobs or contexts
// but should not appear more frequently than the individual token in the create_issue job
// The test is primarily checking that the individual token is used where it should be
if individualCount == 0 && safeOutputsCount > 0 {
t.Error("Individual token should take precedence over safe-outputs token")
}
if individualCount == 0 && toplevelCount > 0 {
t.Error("Individual token should take precedence over top-level token")
}
})

t.Run("top-level token used in codex engine", func(t *testing.T) {
testContent := `---
name: Test Codex Engine Token
on:
workflow_dispatch:
engine: codex
github-token: ${{ secrets.TOPLEVEL_PAT }}
tools:
github:
allowed: [list_issues]
---

# Test Codex Engine Token

Test that top-level github-token is used in Codex engine.
`

testFile := filepath.Join(tmpDir, "test-codex-token.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler(false, "", "test")
err := compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow: %v", err)
}

outputFile := filepath.Join(tmpDir, "test-codex-token.lock.yml")
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}

yamlContent := string(content)

// Verify that the top-level token is used in GH_AW_GITHUB_TOKEN env var
if !strings.Contains(yamlContent, "GH_AW_GITHUB_TOKEN: ${{ secrets.TOPLEVEL_PAT }}") {
t.Error("Expected top-level github-token to be used in GH_AW_GITHUB_TOKEN env var for Codex")
t.Logf("Generated YAML:\n%s", yamlContent)
}

// Also check in the TOML config
if !strings.Contains(yamlContent, "GITHUB_PERSONAL_ACCESS_TOKEN = \"${{ secrets.TOPLEVEL_PAT }}\"") {
t.Error("Expected top-level github-token to be used in TOML config for Codex")
}
})

t.Run("top-level token used in copilot engine", func(t *testing.T) {
testContent := `---
name: Test Copilot Engine Token
on:
workflow_dispatch:
engine: copilot
github-token: ${{ secrets.TOPLEVEL_PAT }}
tools:
github:
allowed: [list_issues]
---

# Test Copilot Engine Token

Test that top-level github-token is used in Copilot engine.
`

testFile := filepath.Join(tmpDir, "test-copilot-token.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler(false, "", "test")
err := compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow: %v", err)
}

outputFile := filepath.Join(tmpDir, "test-copilot-token.lock.yml")
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}

yamlContent := string(content)

// Verify that the top-level token is used in GITHUB_PERSONAL_ACCESS_TOKEN env var
if !strings.Contains(yamlContent, "GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.TOPLEVEL_PAT }}") {
t.Error("Expected top-level github-token to be used in GITHUB_PERSONAL_ACCESS_TOKEN env var for Copilot")
t.Logf("Generated YAML:\n%s", yamlContent)
}
})
}
Loading