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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ The agent container is the threat actor. It can make arbitrary HTTP requests thr
| `OPENAI_API_KEY` | -> `Authorization: Bearer` for `*.openai.com` | |
| `CURSOR_API_KEY` | -> `Authorization: Bearer` for `*.cursor.com`, `*.cursorapi.com` | |
| `GH_TOKEN` | -> `Authorization: Bearer` for `github.com`, `*.githubusercontent.com` | |
| `GOOGLE_APPLICATION_CREDENTIALS` | Path to gcloud ADC JSON | |
| `GCP_ADC_JSON` | Inline gcloud ADC JSON content (preferred over file path) | |
| `GOOGLE_APPLICATION_CREDENTIALS` | Path to gcloud ADC JSON (fallback if `GCP_ADC_JSON` unset) | |

## Credential Routing Table

Expand Down
24 changes: 20 additions & 4 deletions internal/credentials/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,16 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][]
hasCredentials := false
domainMap := make(map[string][]string)

gcpADCJSON := os.Getenv("GCP_ADC_JSON")

for _, entry := range cfg.Credentials {
value := os.Getenv(entry.EnvVar)
if value == "" {

// For gcloud entries, GCP_ADC_JSON takes precedence over the file path
// and allows processing even if GOOGLE_APPLICATION_CREDENTIALS is unset.
if entry.InjectorType == "gcloud" && gcpADCJSON == "" && value == "" {
continue
} else if value == "" && entry.InjectorType != "gcloud" {
continue
}

Expand All @@ -115,9 +122,19 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][]
Key: value,
}
case "gcloud":
gcloudInjector := NewGCloudInjector(value)
var gcloudInjector *GCloudInjector
if gcpADCJSON != "" {
gcloudInjector = NewGCloudInjectorFromJSON([]byte(gcpADCJSON))
log.Println("Using GCP_ADC_JSON env var for gcloud ADC credentials")
} else {
gcloudInjector = NewGCloudInjector(value)
}
if !gcloudInjector.Available() {
log.Printf("WARN: %s=%s but ADC not loadable", entry.EnvVar, value)
if gcpADCJSON != "" {
log.Printf("WARN: GCP_ADC_JSON set but ADC not loadable")
} else {
log.Printf("WARN: %s=%s but ADC not loadable", entry.EnvVar, value)
}
continue
}
injector = gcloudInjector
Expand All @@ -135,7 +152,6 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][]
store.AddRoute(route)
}

// Log the credential route
domainDesc := formatDomains(entry.Domains)
log.Printf("Credential route: %s -> %s", domainDesc, injectorDescription(entry))
hasCredentials = true
Expand Down
109 changes: 109 additions & 0 deletions internal/credentials/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,115 @@ func TestBuildFromConfig_MultipleEntries(t *testing.T) {
}
}

func TestBuildFromConfig_GCloudFromJSON(t *testing.T) {
// GCP_ADC_JSON alone (without GOOGLE_APPLICATION_CREDENTIALS) should work.
t.Setenv("GCP_ADC_JSON", `{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test"}`)
os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")

cfg := &CredentialConfig{
Credentials: []CredentialEntry{
{
EnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
InjectorType: "gcloud",
Domains: []string{".googleapis.com"},
},
},
}

store, tokenVendor, domainMap := BuildFromConfig(cfg)

if tokenVendor == nil {
t.Fatal("tokenVendor should not be nil when GCP_ADC_JSON is set with valid JSON")
}
if _, ok := domainMap["GOOGLE_APPLICATION_CREDENTIALS"]; !ok {
t.Error("domain map should contain GOOGLE_APPLICATION_CREDENTIALS entry")
}

req := &http.Request{
URL: &url.URL{Host: "storage.googleapis.com"},
Header: make(http.Header),
}
if !store.InjectCredentials(req) {
t.Error("should match storage.googleapis.com with gcloud injector from JSON")
}
}

func TestBuildFromConfig_GCloudJSONPreferredOverFile(t *testing.T) {
// GCP_ADC_JSON should be preferred even when GOOGLE_APPLICATION_CREDENTIALS
// points to a nonexistent file (proving the file is never read).
t.Setenv("GCP_ADC_JSON", `{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test"}`)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/path/adc.json")

cfg := &CredentialConfig{
Credentials: []CredentialEntry{
{
EnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
InjectorType: "gcloud",
Domains: []string{".googleapis.com"},
},
},
}

store, tokenVendor, _ := BuildFromConfig(cfg)

if tokenVendor == nil {
t.Fatal("tokenVendor should not be nil — GCP_ADC_JSON should be used instead of the nonexistent file")
}

req := &http.Request{
URL: &url.URL{Host: "storage.googleapis.com"},
Header: make(http.Header),
}
if !store.InjectCredentials(req) {
t.Error("should match storage.googleapis.com with gcloud injector from JSON")
}
}

func TestBuildFromConfig_GCloudFallbackToFile(t *testing.T) {
// When GCP_ADC_JSON is not set, fall back to GOOGLE_APPLICATION_CREDENTIALS file path.
os.Unsetenv("GCP_ADC_JSON")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/path/adc.json")

cfg := &CredentialConfig{
Credentials: []CredentialEntry{
{
EnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
InjectorType: "gcloud",
Domains: []string{".googleapis.com"},
},
},
}

// File doesn't exist, so gcloud should not be available
_, tokenVendor, _ := BuildFromConfig(cfg)
if tokenVendor != nil {
t.Error("tokenVendor should be nil when ADC file doesn't exist and GCP_ADC_JSON is unset")
}
}

func TestBuildFromConfig_GCloudSkippedWhenBothUnset(t *testing.T) {
os.Unsetenv("GCP_ADC_JSON")
os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")

cfg := &CredentialConfig{
Credentials: []CredentialEntry{
{
EnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
InjectorType: "gcloud",
Domains: []string{".googleapis.com"},
},
},
}

_, tokenVendor, domainMap := BuildFromConfig(cfg)
if tokenVendor != nil {
t.Error("tokenVendor should be nil when both GCP_ADC_JSON and GOOGLE_APPLICATION_CREDENTIALS are unset")
}
if _, ok := domainMap["GOOGLE_APPLICATION_CREDENTIALS"]; ok {
t.Error("domain map should not contain entry when both env vars are unset")
}
}

func TestBuildFromConfig_ExactAndSuffixDomains(t *testing.T) {
t.Setenv("TEST_MIX_KEY", "mix-token")

Expand Down
30 changes: 25 additions & 5 deletions internal/credentials/gcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type GCloudInjector struct {
initOnce sync.Once
initErr error
adcPath string
adcJSON []byte
scopes []string
}

Expand All @@ -32,12 +33,28 @@ func NewGCloudInjector(adcPath string) *GCloudInjector {
}
}

// NewGCloudInjectorFromJSON creates an injector from raw ADC JSON content.
// This is preferred over NewGCloudInjector when credentials are passed
// via environment variable rather than mounted as a file.
func NewGCloudInjectorFromJSON(data []byte) *GCloudInjector {
return &GCloudInjector{
adcJSON: data,
scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
}
}

func (g *GCloudInjector) init() error {
g.initOnce.Do(func() {
data, err := os.ReadFile(g.adcPath)
if err != nil {
g.initErr = fmt.Errorf("read ADC file %s: %w", g.adcPath, err)
return
var data []byte
if len(g.adcJSON) > 0 {
data = g.adcJSON
} else {
var err error
data, err = os.ReadFile(g.adcPath)
if err != nil {
g.initErr = fmt.Errorf("read ADC file %s: %w", g.adcPath, err)
return
}
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
Expand Down Expand Up @@ -76,8 +93,11 @@ func (g *GCloudInjector) Inject(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
}

// Available returns true if the ADC file exists and can be loaded.
// Available returns true if ADC credentials can be loaded (from JSON or file).
func (g *GCloudInjector) Available() bool {
if len(g.adcJSON) > 0 {
return g.init() == nil
}
if _, err := os.Stat(g.adcPath); err != nil {
return false
}
Expand Down