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
8 changes: 4 additions & 4 deletions .github/workflows/daily-byok-ollama-test.lock.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/pr-code-quality-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-apikey.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-entra.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ network:

The Copilot engine supports routing requests to an external LLM provider instead of GitHub's default routing. This is useful when you want to use a different model or provider (e.g., OpenAI, Anthropic, Azure OpenAI, or a local Ollama/vLLM instance) while still using the Copilot CLI tooling.

Set `COPILOT_PROVIDER_BASE_URL` in `engine.env` to activate BYOK mode. The credential variables `COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_API_KEY`, and `COPILOT_PROVIDER_BEARER_TOKEN` are explicitly allowed to carry `${{ secrets.* }}` references in `engine.env` under strict mode — they are not leaked to the agent container. Other `COPILOT_PROVIDER_*` variables hold non-sensitive configuration and can be set as plain strings.
Set `COPILOT_PROVIDER_BASE_URL` in `engine.env` to activate BYOK mode. The credential variables `COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_API_KEY`, and `COPILOT_PROVIDER_BEARER_TOKEN` are explicitly allowed to carry `${{ secrets.* }}` references in `engine.env` under strict mode — they are not leaked to the agent container. Other `COPILOT_PROVIDER_*` variables hold non-sensitive configuration and can be set as plain strings. When `COPILOT_PROVIDER_BASE_URL` is a literal URL, gh-aw automatically adds its provider hostname to the AWF allow-list for both the main agent run and the threat-detection Copilot step. When it is supplied via a secret or variable expression, add the provider hostname explicitly to `network.allowed` so the threat-detection step can reuse that concrete host safely.

| Variable | Required | Description |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ Custom GitHub token or GitHub App used by the activation job to post reactions a

### BYOK (Bring Your Own Key)

A Copilot engine mode that routes AI requests to an external LLM provider (such as OpenAI, Anthropic, or a self-hosted Ollama/vLLM instance) instead of the default GitHub Copilot backend. Activated by setting `COPILOT_PROVIDER_BASE_URL` in `engine.env`. The three BYOK credential variables (`COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_API_KEY`, `COPILOT_PROVIDER_BEARER_TOKEN`) accept `${{ secrets.* }}` references under strict mode and are never exposed to the agent container. Use `COPILOT_MODEL` to specify the target model. See [AI Engines Reference](/gh-aw/reference/engines/#copilot-bring-your-own-key-byok-mode).
A Copilot engine mode that routes AI requests to an external LLM provider (such as OpenAI, Anthropic, or a self-hosted Ollama/vLLM instance) instead of the default GitHub Copilot backend. Activated by setting `COPILOT_PROVIDER_BASE_URL` in `engine.env`. The three BYOK credential variables (`COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_API_KEY`, `COPILOT_PROVIDER_BEARER_TOKEN`) accept `${{ secrets.* }}` references under strict mode and are never exposed to the agent container. gh-aw automatically adds the provider hostname to the AWF allow-list when it can resolve a literal BYOK URL, and otherwise reuses the concrete provider hostname from `network.allowed` for the threat-detection Copilot step. Use `COPILOT_MODEL` to specify the target model. See [AI Engines Reference](/gh-aw/reference/engines/#copilot-bring-your-own-key-byok-mode).

### Cron Schedule

Expand Down
90 changes: 90 additions & 0 deletions pkg/workflow/allowed_domains_sanitization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,96 @@ Test workflow with GHE data residency api-target and threat detection.
}
}

func TestCopilotProviderBaseURLInThreatDetectionStep(t *testing.T) {
workflow := `---
on: push
permissions:
contents: read
issues: read
pull-requests: read
engine:
id: copilot
env:
COPILOT_PROVIDER_BASE_URL: ${{ secrets.PROVIDER_BASE_URL }}
network:
allowed:
- defaults
- llm.corp.example.com
strict: false
safe-outputs:
create-issue:
---

# Test Workflow

Test workflow with COPILOT_PROVIDER_BASE_URL in engine.env and provider host in network.allowed.
`

tmpDir := testutil.TempDir(t, "copilot-provider-threat-detection-test")
testFile := filepath.Join(tmpDir, "test-workflow.md")
if err := os.WriteFile(testFile, []byte(workflow), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile workflow: %v", err)
}

lockFile := stringutil.MarkdownToLockFile(testFile)
lockContent, err := os.ReadFile(lockFile)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
lockStr := string(lockContent)

requiredDomain := "llm.corp.example.com"
allowDomainsPrefix := `"allowDomains":[`

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] The hand-rolled allowDomains scan — handling both unescaped and JSON-escaped variants, then manually slicing to find array boundaries — is fragile. If the lock file format changes (e.g., pretty-printed JSON, double-escaped), this silently passes without checking anything meaningful.

💡 Alternative approach

Parse the AWF JSON blob from the lock file with encoding/json or use a helper already in the test suite to extract the firewall config, then assert on the parsed allowDomains slice directly. If structured parsing is overkill here, at minimum use assert.Contains(lockStr, "allowDomains":[+ ... +"llm.corp.example.com") on a known canonical form rather than the dual-format branching.

The occurrenceIdx >= 2 bound check would also benefit from assert.Equal(t, 2, occurrenceIdx) to catch regressions where the lock file grows unexpected extra allowDomains blobs.

allowDomainsPrefixEscaped := `\"allowDomains\":[`
if !strings.Contains(lockStr, allowDomainsPrefix) {
allowDomainsPrefix = allowDomainsPrefixEscaped
}

remaining := lockStr
occurrenceIdx := 0
for {
idx := strings.Index(remaining, allowDomainsPrefix)
if idx < 0 {
break
}
occurrenceIdx++
arrayStart := idx + len(allowDomainsPrefix)
arrayEnd := strings.Index(remaining[arrayStart:], "]")
if arrayEnd < 0 {
arrayEnd = len(remaining) - arrayStart
}
section := remaining[arrayStart : arrayStart+arrayEnd]
if !strings.Contains(section, `"`+requiredDomain+`"`) && !strings.Contains(section, `\"`+requiredDomain+`\"`) {

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.

Test asserts BYOK domain is present in ALL allowDomains occurrences — will spuriously fail when non-agent steps have their own arrays.

💡 Detail and fix

The loop fails the test if ANY allowDomains occurrence in the compiled lock file is missing llm.corp.example.com. But the workflow includes safe-outputs: create-issue:, which generates GitHub-write steps that likely contain their own allowDomains arrays (GitHub API hosts only). Those arrays will not contain the BYOK provider domain, so the test will spuriously fail whenever the safe-outputs step generation adds an allowDomains entry.

The test should verify that the agent job and detection job include the domain, not that every single allowDomains array in the lock file does. A more robust approach:

// Look for allowDomains only inside the agent and detection job blocks.
// Or: check that at least 2 specific named occurrences contain the domain,
// rather than asserting it for every occurrence indiscriminately.
if !strings.Contains(lockStr, `"allowDomains":["llm.corp.example.com`) &&
    !strings.Contains(lockStr, `"llm.corp.example.com"`) {
    t.Error("BYOK provider domain not found in any allowDomains array")
}

Alternatively, parse the lock file as YAML, find the agent and detection steps by step ID, and check those allowDomains arrays specifically.

t.Errorf("allowDomains occurrence #%d is missing BYOK provider domain %q.\nSection: %s", occurrenceIdx, requiredDomain, section)
}
remaining = remaining[arrayStart+arrayEnd:]
}

if occurrenceIdx < 2 {
t.Errorf("Expected at least 2 allowDomains occurrences (main agent + threat detection), found %d", occurrenceIdx)
}

lines := strings.Split(lockStr, "\n")
var domainsLine string
for _, line := range lines {
if strings.Contains(line, "GH_AW_ALLOWED_DOMAINS:") {
domainsLine = line
break
}
}
if domainsLine == "" {
t.Fatal("GH_AW_ALLOWED_DOMAINS not found in compiled lock file")
}
if !strings.Contains(domainsLine, requiredDomain) {
t.Errorf("Expected BYOK provider hostname in GH_AW_ALLOWED_DOMAINS.\nLine: %s", domainsLine)
}
}

// TestAllowedDomainsUnionWithNetworkConfig tests that safe-outputs.allowed-domains
// is unioned with network.allowed and always includes localhost and github.com
func TestAllowedDomainsUnionWithNetworkConfig(t *testing.T) {
Expand Down
65 changes: 65 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,71 @@ func TestGetCopilotAPITarget(t *testing.T) {
}
}

func TestGetCopilotAllowlistTargets(t *testing.T) {
tests := []struct {
name string
workflowData *WorkflowData
expected []string
}{
{
name: "includes BYOK provider host and api-target when both are configured",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
APITarget: "api.acme.ghe.com",
Env: map[string]string{
constants.CopilotProviderBaseURL: "https://llm.corp.example.com/v1",
},
},
},
expected: []string{"llm.corp.example.com", "api.acme.ghe.com"},
},
{
name: "includes only BYOK provider host when no copilot api target is configured",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
constants.CopilotProviderBaseURL: "http://localhost:11434/v1",
},
},
},
expected: []string{"localhost:11434"},
},
{
name: "deduplicates identical provider and api targets",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
APITarget: "llm.corp.example.com",
Env: map[string]string{
constants.CopilotProviderBaseURL: "https://llm.corp.example.com/v1",
},
},
},
expected: []string{"llm.corp.example.com"},
},
{
name: "skips provider host extraction when BYOK base URL is a GitHub expression",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
constants.CopilotProviderBaseURL: "${{ secrets.PROVIDER_BASE_URL }}",
},
},
},
expected: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, GetCopilotAllowlistTargets(tt.workflowData), "GetCopilotAllowlistTargets should return expected targets for %s", tt.name)
})
}
}

// TestCopilotEngineIncludesCopilotAPITargetFromEnvVar tests that the Copilot engine execution
// step includes the copilot API target in the JSON config when GITHUB_COPILOT_BASE_URL is
// configured in engine.env.
Expand Down
9 changes: 5 additions & 4 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,10 +399,11 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
} else {
allowedDomains = GetAllowedDomainsForEngine(constants.CopilotEngine, workflowData.NetworkPermissions, workflowData.Tools, workflowData.Runtimes)
}
// Add Copilot API target domains to the firewall allow-list.
// Resolved from engine.api-target or GITHUB_COPILOT_BASE_URL in engine.env.
if copilotAPITarget := GetCopilotAPITarget(workflowData); copilotAPITarget != "" {
allowedDomains = mergeAPITargetDomains(allowedDomains, copilotAPITarget)
// Add Copilot BYOK/API target domains to the firewall allow-list.
// This keeps normal and detection runs in sync while preserving detection's
// otherwise-minimal network footprint.
for _, copilotTarget := range GetCopilotAllowlistTargets(workflowData) {
allowedDomains = mergeAPITargetDomains(allowedDomains, copilotTarget)
}

// AWF v0.15.0+ uses chroot mode by default, providing transparent access to host binaries
Expand Down
8 changes: 4 additions & 4 deletions pkg/workflow/domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,10 +969,10 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) (str
base = strings.Join(domains, ",")
}

// Add Copilot API target domains so GH_AW_ALLOWED_DOMAINS stays in sync with --allow-domains.
// Resolved from engine.api-target or GITHUB_COPILOT_BASE_URL in engine.env.
if copilotAPITarget := GetCopilotAPITarget(data); copilotAPITarget != "" {
base = mergeAPITargetDomains(base, copilotAPITarget)
// Add Copilot BYOK/API target domains so GH_AW_ALLOWED_DOMAINS stays in sync with
// the runtime firewall allow-list for both standard and BYOK Copilot runs.
for _, copilotTarget := range GetCopilotAllowlistTargets(data) {
base = mergeAPITargetDomains(base, copilotTarget)
}

// Add Antigravity API target domains so GH_AW_ALLOWED_DOMAINS stays in sync with --allow-domains.
Expand Down
Loading
Loading