diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 3fc711d4035..6f24708a24e 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -37,6 +37,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/github-agentic-workflows.md` - `.github/aw/github-mcp-server.md` - `.github/aw/instructions.md` +- `.github/aw/linter-workflows.md` - `.github/aw/llms.md` - `.github/aw/loop.md` - `.github/aw/lsp.md` diff --git a/docs/adr/48185-google-vertex-ai-wif-auth-for-gemini-engine.md b/docs/adr/48185-google-vertex-ai-wif-auth-for-gemini-engine.md new file mode 100644 index 00000000000..f421ad390d3 --- /dev/null +++ b/docs/adr/48185-google-vertex-ai-wif-auth-for-gemini-engine.md @@ -0,0 +1,85 @@ +# ADR-48185: Google Vertex AI Workload Identity Federation Auth for Gemini Engine + +**Date**: 2026-07-26 +**Status**: Accepted +**Deciders**: Gemini engine maintainers + +--- + +### Context + +Enterprise Gemini workloads on Google Vertex AI and the Gemini Enterprise Agent Platform require keyless +authentication rather than a static `GEMINI_API_KEY` long-lived secret. Storing long-lived API keys in +GitHub repository secrets creates a credential-rotation burden and a higher blast radius on compromise. +The `engine.auth` mechanism already supports a `type: github-oidc` + `provider: ` pattern for +Anthropic WIF (Claude engine), establishing a precedent for exchanging short-lived GitHub OIDC tokens +for provider-specific credentials. The goal is to give Gemini the same keyless alternative without +disrupting existing `GEMINI_API_KEY`-based workflows. + +### Decision + +We add a `provider: gcp` discriminator to `engine.auth` that enables Google Cloud Workload +Identity Federation (WIF) for the Gemini engine. When `type: github-oidc` and `provider: gcp` are +set with the three required fields (`workload-identity-provider`, `service-account`, `project`), the +compiler skips the `GEMINI_API_KEY` secret requirement, suppresses static-key validation, switches +the Gemini CLI to the Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits +`AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER` and `AWF_AUTH_GCP_SERVICE_ACCOUNT` environment variables +for the AWF api-proxy sidecar. The `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` vars are set +directly for the Gemini CLI (defaulting `location` to `us-central1`). The Vertex AI env vars are +applied after any `engine.env` merge so they cannot be overridden by users. The feature is +implemented by extending `EngineAuthConfig` with four new fields (`GoogleWorkloadIdentityProvider`, +`GoogleServiceAccount`, `GoogleProject`, `GoogleLocation`), parsing them in `engine_config_parser.go`, +adding `validateGCPWIFEngineAuth()` for compile-time required-field validation, and branching on +`isGeminiVertexWIF()` in the Gemini engine compilation path. Existing `GEMINI_API_KEY` flows are +unchanged. The provider name `gcp` was chosen to match the AWF firewall's existing OIDC provider +contract (`AWF_AUTH_PROVIDER=gcp`). + +### Alternatives Considered + +#### Alternative 1: Keep `GEMINI_API_KEY` as the Only Auth Path + +The Gemini engine continues to require a static API key, with no keyless option. This avoids any +change to the auth pipeline and the schema. It was not chosen because it blocks enterprise Vertex AI +workloads that prohibit long-lived secrets and cannot adopt the tool without a keyless path. The +Anthropic WIF precedent already established that a keyless alternative is the right direction for +enterprise engine adoption. + +#### Alternative 2: Generic Provider Plugin / Extension Point + +Introduce a fully generic WIF provider registry so any engine can declare its own WIF fields without +touching core auth structs. This would be more extensible but requires a significant refactor of the +auth pipeline, parser, and schema — far more complexity than warranted for a single new provider. +The simpler flat-field extension approach (consistent with how Anthropic and Azure WIF fields are +structured today) achieves the same goal with minimal risk and easier auditability. + +### Consequences + +#### Positive +- Enables enterprise Vertex AI / Gemini Enterprise workloads that require zero long-lived secrets in + the repository. +- Consistent with the existing `type: github-oidc` + `provider:` discriminator pattern for Anthropic + WIF, reducing conceptual surface area for users already familiar with Claude engine keyless auth. +- Short-lived OIDC tokens exchanged via Google Cloud WIF reduce blast radius vs. a static API key + stored indefinitely in repo secrets. + +#### Negative +- Users must pre-configure a Google Cloud Workload Identity Pool, Provider, and service account with + appropriate Vertex AI permissions — substantially more setup than providing a single `GEMINI_API_KEY`. +- Four new fields added to `EngineAuthConfig` struct and the `engine.auth` JSON schema; the parser and + compiler now have an additional branch (`isGeminiVertexWIF`) to maintain and test for each future + Gemini engine change. +- The `service-account` and `project` keys in `engine.auth` are shared key names without a `gcp-` + prefix, which could cause ambiguity if a future provider also uses these field names for different + semantics. This is accepted as a known trade-off consistent with the existing `engine.auth` pattern. + +#### Neutral +- The `location` field is optional and defaults to `us-central1` when omitted. The compile-time + validation (`validateGCPWIFEngineAuth()`) only checks for the presence of the required fields + (`workload-identity-provider`, `service-account`, `project`); it does not validate that the region + string is valid, so an invalid region will fail at runtime rather than compile time. +- The AWF api-proxy sidecar handles the actual OIDC token exchange with Google Cloud; this ADR covers + only the compiler-side changes that emit the required `AWF_AUTH_GCP_*` environment variables. + +--- + +*ADR accepted. Provider name changed from `google` to `gcp` to match the AWF firewall OIDC contract.* diff --git a/docs/src/content/docs/reference/auth.mdx b/docs/src/content/docs/reference/auth.mdx index 6802ac111d1..dc98c90b419 100644 --- a/docs/src/content/docs/reference/auth.mdx +++ b/docs/src/content/docs/reference/auth.mdx @@ -33,9 +33,8 @@ Configure the authentication method your engine needs before running your first ### Gemini -- **Required secret:** [`GEMINI_API_KEY`](#gemini_api_key) -- **Alternative:** None -- **Notes:** API key from Google AI Studio +- **Standard:** [`GEMINI_API_KEY`](#gemini_api_key) — static API key from Google AI Studio +- **Keyless alternative:** [`engine.auth` Google WIF](#google-workload-identity-federation-wif) — short-lived OIDC token via Google Cloud Workload Identity Federation; no long-lived secret on the repo Most workflows will run without any additional secrets or additional authentication beyond this one engine secret. @@ -339,6 +338,66 @@ See also [AI Engines](/gh-aw/reference/engines/#available-coding-agents) for add --- +### Google Workload Identity Federation (WIF) + +Workload Identity Federation lets workflows authenticate with Gemini using short-lived +GitHub OIDC tokens exchanged for Google Cloud credentials instead of a long-lived `GEMINI_API_KEY` secret. +When WIF is active, the compiler suppresses the static-key requirement, switches the Gemini CLI to the +Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits `AWF_AUTH_GCP_*` environment variables +consumed by the AWF firewall api-proxy sidecar. + +**Prerequisites:** +- A Google Cloud Workload Identity Pool and Provider configured for GitHub Actions + (see [Google Cloud WIF for GitHub Actions](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines)) +- A Google Cloud service account with Vertex AI User permissions, granted impersonation from the WIF provider +- `permissions: id-token: write` in the workflow job + +**Frontmatter:** + +```yaml wrap +permissions: + contents: read + id-token: write + +engine: + id: gemini + auth: + type: github-oidc + provider: gcp + workload-identity-provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER + service-account: my-sa@my-project.iam.gserviceaccount.com + project: my-project + # Optional: Cloud region (defaults to us-central1) + # location: us-central1 +``` + +**Fields:** + +| Field | Required | Description | +|---|---|---| +| `workload-identity-provider` | ✅ | Full resource name of the Google Cloud WIF provider | +| `service-account` | ✅ | Service account email to impersonate | +| `project` | ✅ | Google Cloud project ID for Vertex AI inference | +| `location` | Optional | Cloud region for Vertex AI (default: `us-central1`) | + +**Emitted environment variables:** + +The compiler maps each field to an env var passed to the AWF api-proxy sidecar and sets the +Vertex AI backend env vars for the Gemini CLI: + +| Field | Env var | +|---|---| +| `type: github-oidc` | `AWF_AUTH_TYPE=github-oidc` | +| `provider: gcp` | `AWF_AUTH_PROVIDER=gcp` | +| `workload-identity-provider` | `AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER` | +| `service-account` | `AWF_AUTH_GCP_SERVICE_ACCOUNT` | +| `project` | `GOOGLE_CLOUD_PROJECT` | +| `location` | `GOOGLE_CLOUD_LOCATION` (defaults to `us-central1`) | + +Additionally, `GOOGLE_GENAI_USE_VERTEXAI=true` is always set when GCP WIF is active. + +--- + ## Troubleshooting auth errors Common authentication errors and how to resolve them: diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 67d3b4e9448..d3f8457e376 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -16,7 +16,7 @@ Set `engine:` in your workflow frontmatter and configure the corresponding secre | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli) (default) | `copilot` | [`copilot-requests: write`](/gh-aw/reference/auth/#copilot-requests-write-permission) (recommended) or [`COPILOT_GITHUB_TOKEN`](/gh-aw/reference/auth/#copilot_github_token) | | [Claude by Anthropic (Claude Code)](https://www.anthropic.com/index/claude) | `claude` | [`ANTHROPIC_API_KEY`](/gh-aw/reference/auth/#anthropic_api_key) (standard) or [`engine.auth` Anthropic WIF](/gh-aw/reference/auth/#anthropic-workload-identity-federation-wif) (keyless) | | [OpenAI Codex](https://openai.com/blog/openai-codex) | `codex` | [OPENAI_API_KEY](/gh-aw/reference/auth/#openai_api_key) | -| [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | [GEMINI_API_KEY](/gh-aw/reference/auth/#gemini_api_key) | +| [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | [`GEMINI_API_KEY`](/gh-aw/reference/auth/#gemini_api_key) (standard) or [`engine.auth` Google WIF](/gh-aw/reference/auth/#google-workload-identity-federation-wif) (keyless) | | [OpenCode](https://opencode.ai) (experimental) | `opencode` | [COPILOT_GITHUB_TOKEN](/gh-aw/reference/auth/#copilot_github_token) | | [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (experimental) | `pi` | [COPILOT_GITHUB_TOKEN](/gh-aw/reference/auth/#copilot_github_token) (default); switches to provider-specific secret when `model:` uses `provider/model` format | diff --git a/pkg/cli/compile_wif_google_gemini_integration_test.go b/pkg/cli/compile_wif_google_gemini_integration_test.go new file mode 100644 index 00000000000..15227c84537 --- /dev/null +++ b/pkg/cli/compile_wif_google_gemini_integration_test.go @@ -0,0 +1,60 @@ +//go:build integration + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompileGeminiWIFGoogle verifies that a workflow using Google +// Workload Identity Federation (engine.auth.type=github-oidc with +// provider=google) compiles successfully without requiring GEMINI_API_KEY, +// and that the WIF fields are correctly emitted as env vars in the lock file. +func TestCompileGeminiWIFGoogle(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Copy the canonical Google WIF workflow fixture into the test's .github/workflows dir + srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-gemini-wif-google.md") + dstPath := filepath.Join(setup.workflowsDir, "test-gemini-wif-google.md") + + srcContent, err := os.ReadFile(srcPath) + require.NoError(t, err, "Failed to read source workflow file %s", srcPath) + require.NoError(t, os.WriteFile(dstPath, srcContent, 0644), "Failed to write workflow to test dir") + + // Compile the workflow - it must succeed (exit 0) without GEMINI_API_KEY. + cmd := exec.Command(setup.binaryPath, "compile", dstPath) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Gemini WIF Google workflow must compile without error:\n%s", string(output)) + + // Verify the lock file was created and contains the expected WIF env vars. + lockFilePath := filepath.Join(setup.workflowsDir, "test-gemini-wif-google.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "Expected lock file %s to be created", lockFilePath) + lockStr := string(lockContent) + + // All WIF fields from the fixture must be emitted as env vars in the compiled lock + // file. Checking for "KEY: value" pairs ensures both the key and the value round-trip + // correctly through the schema → parser → compiler pipeline. + assert.Contains(t, lockStr, "AWF_AUTH_PROVIDER: gcp", "lock file should contain AWF_AUTH_PROVIDER=gcp") + assert.Contains(t, lockStr, "AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github", + "lock file should contain AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER") + assert.Contains(t, lockStr, "AWF_AUTH_GCP_SERVICE_ACCOUNT: my-sa@my-project.iam.gserviceaccount.com", + "lock file should contain AWF_AUTH_GCP_SERVICE_ACCOUNT") + + // Vertex AI backend env var and project/location must be set + assert.Contains(t, lockStr, "GOOGLE_GENAI_USE_VERTEXAI: true", "lock file should set GOOGLE_GENAI_USE_VERTEXAI=true") + assert.Contains(t, lockStr, "GOOGLE_CLOUD_PROJECT: my-project", "lock file should set GOOGLE_CLOUD_PROJECT") + assert.Contains(t, lockStr, "GOOGLE_CLOUD_LOCATION: us-central1", "lock file should set GOOGLE_CLOUD_LOCATION") + + // GEMINI_API_KEY must NOT appear in the lock file when WIF is configured + assert.NotContains(t, lockStr, "GEMINI_API_KEY", "lock file must not contain GEMINI_API_KEY when Google WIF is configured") + + t.Logf("Google WIF workflow compiled successfully to %s", lockFilePath) +} diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json index 2165df7dc76..ba497b7d5cc 100644 --- a/pkg/cli/data/agentic_workflows_fallback_aw_files.json +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -21,6 +21,7 @@ "github-agentic-workflows.md", "github-mcp-server.md", "instructions.md", + "linter-workflows.md", "llms.md", "loop.md", "lsp.md", diff --git a/pkg/cli/workflows/test-gemini-wif-google.md b/pkg/cli/workflows/test-gemini-wif-google.md new file mode 100644 index 00000000000..efe59d6d849 --- /dev/null +++ b/pkg/cli/workflows/test-gemini-wif-google.md @@ -0,0 +1,26 @@ +--- +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +engine: + id: gemini + auth: + type: github-oidc + provider: gcp + workload-identity-provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github + service-account: my-sa@my-project.iam.gserviceaccount.com + project: my-project + location: us-central1 + +network: defaults + +timeout-minutes: 5 +--- + +# Google Vertex AI WIF schema test + +Echo "ok". diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3a8bc6e2720..972da70bd1f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -12434,7 +12434,7 @@ }, "provider": { "type": "string", - "description": "Optional WIF provider discriminator. Recognized values are 'azure' and 'anthropic'." + "description": "Optional WIF provider discriminator. Recognized values are 'azure', 'anthropic', and 'gcp'." }, "federation-rule-id": { "type": "string", @@ -12451,6 +12451,22 @@ "workspace-id": { "type": "string", "description": "Anthropic WIF workspace ID (e.g., ws_...)." + }, + "workload-identity-provider": { + "type": "string", + "description": "Google Cloud WIF workload identity provider resource name (e.g., projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER)." + }, + "service-account": { + "type": "string", + "description": "Google Cloud service account email to impersonate via WIF (e.g., my-sa@my-project.iam.gserviceaccount.com)." + }, + "project": { + "type": "string", + "description": "Google Cloud project ID used for Vertex AI / Gemini Enterprise inference." + }, + "location": { + "type": "string", + "description": "Google Cloud region for Vertex AI inference (e.g., us-central1). Defaults to us-central1 when omitted." } }, "required": ["type"], diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index effcca7d659..93e1c9d0978 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -184,6 +184,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar {logMessage: "Validating max-daily-ai-credits frontmatter", validateFn: func() error { return validateMaxDailyAICFrontmatter(workflowData) }}, {logMessage: "Validating private-to-public-flows string value", validateFn: func() error { return validatePrivateToPublicFlowsStringValue(workflowData) }}, {logMessage: "Validating private-to-public-flows server IDs", validateFn: func() error { return validatePrivateToPublicFlowsServerIDs(workflowData) }}, + {logMessage: "Validating GCP WIF engine auth required fields", validateFn: func() error { return validateGCPWIFEngineAuth(workflowData) }}, } // This validation is intentionally outside the table below because strict mode // turns the same validation result into either an error or a warning. @@ -424,3 +425,32 @@ func hasWeightedTrafficExperiment(configs map[string]*ExperimentConfig) bool { } return false } + +// validateGCPWIFEngineAuth returns an error when engine.auth declares +// provider=gcp with type=github-oidc but is missing one or more of the three +// required fields (workload-identity-provider, service-account, project). +// Without these fields the WIF exchange cannot succeed and GEMINI_API_KEY will +// also be absent, causing a guaranteed runtime failure that is hard to diagnose. +func validateGCPWIFEngineAuth(workflowData *WorkflowData) error { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil { + return nil + } + auth := workflowData.EngineConfig.Auth + if auth.Type != "github-oidc" || auth.Provider != "gcp" { + return nil + } + var missing []string + if auth.GoogleWorkloadIdentityProvider == "" { + missing = append(missing, "workload-identity-provider") + } + if auth.GoogleServiceAccount == "" { + missing = append(missing, "service-account") + } + if auth.GoogleProject == "" { + missing = append(missing, "project") + } + if len(missing) > 0 { + return fmt.Errorf("engine.auth with provider=gcp requires the following fields: %s", strings.Join(missing, ", ")) + } + return nil +} diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 9fa3d596594..ea7b35de549 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -121,7 +121,7 @@ type InlineEngineDriver struct { type EngineAuthConfig struct { Type string Audience string - Provider string // "azure" or "anthropic" + Provider string // "azure", "anthropic", or "gcp" // Azure WIF fields AzureTenantID string AzureClientID string @@ -132,6 +132,11 @@ type EngineAuthConfig struct { AnthropicOrganizationID string AnthropicServiceAccountID string AnthropicWorkspaceID string + // Google / Vertex AI WIF fields + GoogleWorkloadIdentityProvider string + GoogleServiceAccount string + GoogleProject string + GoogleLocation string } // NetworkPermissions represents network access permissions for workflow execution @@ -727,6 +732,8 @@ func applyEngineAuthEnv(config *EngineConfig) { setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_ORGANIZATION_ID", config.Auth.AnthropicOrganizationID) setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID", config.Auth.AnthropicServiceAccountID) setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_WORKSPACE_ID", config.Auth.AnthropicWorkspaceID) + setEngineAuthEnv(config.Env, "AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER", config.Auth.GoogleWorkloadIdentityProvider) + setEngineAuthEnv(config.Env, "AWF_AUTH_GCP_SERVICE_ACCOUNT", config.Auth.GoogleServiceAccount) } func setEngineAuthEnv(env map[string]string, key, value string) { diff --git a/pkg/workflow/engine_config_parser.go b/pkg/workflow/engine_config_parser.go index 027aa9ab82d..aa87f02d9d6 100644 --- a/pkg/workflow/engine_config_parser.go +++ b/pkg/workflow/engine_config_parser.go @@ -156,6 +156,18 @@ func parseEngineAuthConfig(authObj map[string]any) *EngineAuthConfig { if s, ok := authObj["workspace-id"].(string); ok { auth.AnthropicWorkspaceID = s } + if s, ok := authObj["workload-identity-provider"].(string); ok { + auth.GoogleWorkloadIdentityProvider = s + } + if s, ok := authObj["service-account"].(string); ok { + auth.GoogleServiceAccount = s + } + if s, ok := authObj["project"].(string); ok { + auth.GoogleProject = s + } + if s, ok := authObj["location"].(string); ok { + auth.GoogleLocation = s + } return auth } diff --git a/pkg/workflow/gemini_engine.go b/pkg/workflow/gemini_engine.go index d91618ac8a0..27c6c0be82a 100644 --- a/pkg/workflow/gemini_engine.go +++ b/pkg/workflow/gemini_engine.go @@ -46,10 +46,16 @@ func (e *GeminiEngine) GetModelEnvVarName() string { // GetRequiredSecretNames returns the list of secrets required by the Gemini engine // This includes GEMINI_API_KEY and optionally MCP_GATEWAY_API_KEY, GITHUB_MCP_SERVER_TOKEN, -// HTTP MCP header secrets, and mcp-scripts secrets +// HTTP MCP header secrets, and mcp-scripts secrets. +// When Google/Vertex WIF (github-oidc + provider=google) is configured, no static API key +// is needed and only common MCP secrets are returned. func (e *GeminiEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { geminiLog.Print("Collecting required secrets for Gemini engine") - secrets := []string{"GEMINI_API_KEY"} + + var secrets []string + if !isGeminiVertexWIF(workflowData) { + secrets = append(secrets, "GEMINI_API_KEY") + } // Add common MCP secrets (MCP_GATEWAY_API_KEY if MCP servers present, mcp-scripts secrets) secrets = append(secrets, collectCommonMCPSecrets(workflowData)...) @@ -81,8 +87,11 @@ func (e *GeminiEngine) GetSupportedEnvVarKeys() []string { } // GetSecretValidationStep returns the secret validation step for the Gemini engine. -// Returns an empty step if custom command is specified. +// Returns an empty step if custom command is specified or if Google/Vertex WIF is configured. func (e *GeminiEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { + if isGeminiVertexWIF(workflowData) { + return GitHubActionStep{} + } return BuildDefaultSecretValidationStep( workflowData, []string{"GEMINI_API_KEY"}, @@ -91,6 +100,20 @@ func (e *GeminiEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHu ) } +// isGeminiVertexWIF returns true when the workflow is configured to use Google +// Workload Identity Federation (github-oidc auth type with provider=gcp) and +// has the required fields set (workload-identity-provider, service-account, project). +func isGeminiVertexWIF(workflowData *WorkflowData) bool { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil { + return false + } + auth := workflowData.EngineConfig.Auth + return auth.Type == "github-oidc" && auth.Provider == "gcp" && + auth.GoogleWorkloadIdentityProvider != "" && + auth.GoogleServiceAccount != "" && + auth.GoogleProject != "" +} + func (e *GeminiEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { geminiLog.Printf("Generating installation steps for Gemini engine: workflow=%s", workflowData.Name) @@ -248,7 +271,7 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str PathSetup: "touch " + AgentStepSummaryPath, // Exclude every env var whose step-env value is a secret so the agent // cannot read raw token values via bash tools (env / printenv). - ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"GEMINI_API_KEY"}), + ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, e.GetRequiredSecretNames(workflowData)), }) } else { command = fmt.Sprintf(`set -o pipefail @@ -259,9 +282,9 @@ touch %s } // Build environment variables + vertexWIF := isGeminiVertexWIF(workflowData) env := map[string]string{ - "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", - "GH_AW_PROMPT": constants.AwPromptsFile, + "GH_AW_PROMPT": constants.AwPromptsFile, // Tag the step as a GitHub AW agentic execution for discoverability by agents "GITHUB_AW": "true", "GITHUB_WORKSPACE": "${{ github.workspace }}", @@ -280,6 +303,12 @@ touch %s // approval mode when the workspace is untrusted, which causes exit code 55. "GEMINI_CLI_TRUST_WORKSPACE": "true", } + if !vertexWIF { + // Set static API key when WIF is not configured. + // When WIF is active, authentication is handled by the AWF api-proxy sidecar + // via the AWF_AUTH_GCP_* env vars set through engine.auth. + env["GEMINI_API_KEY"] = "${{ secrets.GEMINI_API_KEY }}" + } injectWorkflowCallNetworkAllowedEnv(env, workflowData) // Indicate the phase: "agent" for the main run, "detection" for threat detection, // and "evals" for the eval harness execution. @@ -344,6 +373,20 @@ touch %s geminiLog.Printf("Added %d custom env vars from agent config", len(agentConfig.Env)) } + // Apply Vertex AI WIF env vars AFTER engine.env and agent.env merges to ensure + // they cannot be overridden by user-provided engine.env values. + if vertexWIF { + auth := workflowData.EngineConfig.Auth + // Gemini CLI v0.39+ selects Vertex AI backend when this is set to "true". + env["GOOGLE_GENAI_USE_VERTEXAI"] = "true" + env["GOOGLE_CLOUD_PROJECT"] = auth.GoogleProject + location := auth.GoogleLocation + if location == "" { + location = "us-central1" + } + env["GOOGLE_CLOUD_LOCATION"] = location + } + // Generate the execution step stepLines := []string{ " - name: Execute Gemini CLI", diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index 556c678e707..a476afeb142 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -599,6 +599,143 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { }) } +func TestGeminiVertexWIF(t *testing.T) { + engine := NewGeminiEngine() + + makeVertexWIFData := func(project, location string) *WorkflowData { + return &WorkflowData{ + Name: "test-vertex-wif", + ParsedTools: &ToolsConfig{}, + Tools: map[string]any{}, + EngineConfig: &EngineConfig{ + ID: "gemini", + Auth: &EngineAuthConfig{ + Type: "github-oidc", + Provider: "gcp", + GoogleWorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/github", + GoogleServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + GoogleProject: project, + GoogleLocation: location, + }, + }, + } + } + + t.Run("isGeminiVertexWIF returns true when auth type is github-oidc and provider is gcp with required fields", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "us-central1") + assert.True(t, isGeminiVertexWIF(wd), "Should detect Vertex WIF") + }) + + t.Run("isGeminiVertexWIF returns false when no auth", func(t *testing.T) { + wd := &WorkflowData{Name: "test"} + assert.False(t, isGeminiVertexWIF(wd), "Should not detect WIF when no auth") + }) + + t.Run("isGeminiVertexWIF returns false when provider is not gcp", func(t *testing.T) { + wd := &WorkflowData{ + Name: "test", + EngineConfig: &EngineConfig{ + Auth: &EngineAuthConfig{ + Type: "github-oidc", + Provider: "anthropic", + }, + }, + } + assert.False(t, isGeminiVertexWIF(wd), "Should not detect WIF when provider is not gcp") + }) + + t.Run("isGeminiVertexWIF returns false when required fields are missing", func(t *testing.T) { + wd := &WorkflowData{ + Name: "test", + EngineConfig: &EngineConfig{ + Auth: &EngineAuthConfig{ + Type: "github-oidc", + Provider: "gcp", + // Missing: GoogleWorkloadIdentityProvider, GoogleServiceAccount, GoogleProject + }, + }, + } + assert.False(t, isGeminiVertexWIF(wd), "Should not detect WIF when required fields are missing") + }) + + t.Run("GEMINI_API_KEY not required when Vertex WIF is configured", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "us-central1") + secrets := engine.GetRequiredSecretNames(wd) + assert.NotContains(t, secrets, "GEMINI_API_KEY", "Should not require GEMINI_API_KEY with Vertex WIF") + }) + + t.Run("GEMINI_API_KEY still required without Vertex WIF", func(t *testing.T) { + wd := &WorkflowData{ + Name: "test", + ParsedTools: &ToolsConfig{}, + Tools: map[string]any{}, + } + secrets := engine.GetRequiredSecretNames(wd) + assert.Contains(t, secrets, "GEMINI_API_KEY", "Should require GEMINI_API_KEY without WIF") + }) + + t.Run("secret validation step is empty when Vertex WIF is configured", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "us-central1") + step := engine.GetSecretValidationStep(wd) + assert.Empty(t, step, "Should return empty validation step with Vertex WIF") + }) + + t.Run("secret validation step present without Vertex WIF", func(t *testing.T) { + wd := &WorkflowData{Name: "test"} + step := engine.GetSecretValidationStep(wd) + assert.NotEmpty(t, step, "Should return validation step without WIF") + }) + + t.Run("execution step uses Vertex AI env vars when WIF configured", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "us-central1") + steps := engine.GetExecutionSteps(wd, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "GOOGLE_GENAI_USE_VERTEXAI: true", "Should set Vertex AI backend env var to 'true'") + assert.Contains(t, stepContent, "GOOGLE_CLOUD_PROJECT: my-project", "Should set project env var") + assert.Contains(t, stepContent, "GOOGLE_CLOUD_LOCATION: us-central1", "Should set location env var") + assert.NotContains(t, stepContent, "GEMINI_API_KEY", "Should not include GEMINI_API_KEY with Vertex WIF") + }) + + t.Run("execution step defaults location to us-central1 when not configured", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "") + steps := engine.GetExecutionSteps(wd, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "GOOGLE_GENAI_USE_VERTEXAI: true", "Should set Vertex AI backend env var to 'true'") + assert.Contains(t, stepContent, "GOOGLE_CLOUD_PROJECT: my-project", "Should set project env var") + assert.Contains(t, stepContent, "GOOGLE_CLOUD_LOCATION: us-central1", "Should default location to us-central1") + assert.NotContains(t, stepContent, "GEMINI_API_KEY", "Should not include GEMINI_API_KEY with Vertex WIF") + }) + + t.Run("execution step uses GEMINI_API_KEY without Vertex WIF", func(t *testing.T) { + wd := &WorkflowData{Name: "test"} + steps := engine.GetExecutionSteps(wd, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}", "Should include GEMINI_API_KEY without WIF") + assert.NotContains(t, stepContent, "GOOGLE_GENAI_USE_VERTEXAI", "Should not set Vertex AI vars without WIF") + }) + + t.Run("engine.env cannot overwrite WIF-emitted Vertex AI env vars", func(t *testing.T) { + wd := makeVertexWIFData("my-project", "us-central1") + // User tries to override WIF vars via engine.env — compiler must ignore them. + wd.EngineConfig.Env = map[string]string{ + "GOOGLE_GENAI_USE_VERTEXAI": "false", + "GOOGLE_CLOUD_PROJECT": "other-project", + } + steps := engine.GetExecutionSteps(wd, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "GOOGLE_GENAI_USE_VERTEXAI: true", "WIF env var must not be overridden by engine.env") + assert.Contains(t, stepContent, "GOOGLE_CLOUD_PROJECT: my-project", "WIF project must not be overridden by engine.env") + }) +} + func TestGeminiEngineWithExpressionVersion(t *testing.T) { engine := NewGeminiEngine()