diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b455897..87c282d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Moat runs AI coding agents in isolated containers with credential injection, net Moat is pre-1.0. The CLI interface and `moat.yaml` schema may change between minor versions. Breaking changes are listed under **Breaking** headings below. +## Unreleased + +### Added + +- **gcloud credential provider** — authenticate the Google Cloud CLI and client libraries inside a moat sandbox without leaking refresh tokens or service account keys. The host daemon mints short-lived access tokens via Application Default Credentials and serves them through an emulated GCE metadata server. Use `moat grant gcloud` then `moat run --grant gcloud`. + ## v0.5.0 — 2026-04-07 v0.5 hardens network isolation and introduces operation-level policy enforcement on MCP tool calls and HTTP traffic. Host traffic is now blocked by default in every network policy mode — including `permissive` — and must be opted into per-port with `network.host`. Keep policy integration adds allow/deny/redact rules for MCP tool calls and REST API requests, with starter packs for common services and an LLM response policy that evaluates `tool_use` blocks before forwarding to the container. The credential-injecting proxy is now also available as a standalone `gatekeeper` binary that runs without the moat runtime. Other additions include multi-credential per host, custom base images, OAuth grants for MCP servers, sandbox-local MCP servers, and global mounts in `~/.moat/config.yaml`. diff --git a/cmd/moat/cli/daemon.go b/cmd/moat/cli/daemon.go index 166deb34..125e38d9 100644 --- a/cmd/moat/cli/daemon.go +++ b/cmd/moat/cli/daemon.go @@ -2,6 +2,7 @@ package cli import ( "context" + "net/http" "os" "os/signal" "path/filepath" @@ -71,6 +72,13 @@ func runDaemon(_ *cobra.Command, _ []string) error { return rc.ToProxyContextData(), true }) + // Wire the gcloud direct resolver for metadata requests that bypass HTTP_PROXY. + // Python's google-auth uses bare http.client for GCE detection, so when + // GCE_METADATA_HOST points at the proxy, these arrive without Proxy-Authorization. + p.SetGCloudDirectResolver(func() http.Handler { + return apiServer.Registry().FindGCloudHandler() + }) + // Wire network request logging. The proxy is shared across runs, so // the logger routes to per-run storage using the RunID from request context. var storeMu sync.Mutex diff --git a/cmd/moat/cli/grant.go b/cmd/moat/cli/grant.go index 14f7fe83..4575a024 100644 --- a/cmd/moat/cli/grant.go +++ b/cmd/moat/cli/grant.go @@ -12,6 +12,7 @@ import ( "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" "github.com/majorcontext/moat/internal/providers/aws" + "github.com/majorcontext/moat/internal/providers/gcloud" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -25,6 +26,14 @@ var ( awsProfile string ) +// gcloud grant flags - these need to be passed to the gcloud provider +var ( + gcloudProject string + gcloudScopes string + gcloudImpersonateSA string + gcloudKeyFile string +) + var grantCmd = &cobra.Command{ Use: "grant ", Short: "Grant a credential for use in runs", @@ -48,6 +57,7 @@ Examples: moat grant anthropic # Grant Anthropic API key (for any agent) moat grant github # Grant GitHub access moat grant aws --role=arn:aws:... # Grant AWS access via IAM role + moat grant gcloud --project my-project # Grant Google Cloud access moat grant github --profile myproject # Grant GitHub access in a profile moat grant providers # List all available providers moat run my-agent . --grant github # Use credential in a run @@ -63,6 +73,12 @@ func init() { grantCmd.Flags().StringVar(&awsSessionDuration, "session-duration", "", "Session duration (default: 15m, max: 12h)") grantCmd.Flags().StringVar(&awsExternalID, "external-id", "", "External ID for role assumption") grantCmd.Flags().StringVar(&awsProfile, "aws-profile", "", "AWS shared config profile for role assumption (falls back to AWS_PROFILE env var if not set)") + + // gcloud flags + grantCmd.Flags().StringVar(&gcloudProject, "project", "", "Google Cloud project ID") + grantCmd.Flags().StringVar(&gcloudScopes, "scopes", "", "OAuth scopes (comma-separated, default: cloud-platform)") + grantCmd.Flags().StringVar(&gcloudImpersonateSA, "impersonate-service-account", "", "Service account email to impersonate via IAM") + grantCmd.Flags().StringVar(&gcloudKeyFile, "key-file", "", "Path to service account key file") } // saveCredential stores a credential and returns the file path. @@ -128,6 +144,11 @@ Options: ctx = aws.WithGrantOptions(ctx, awsRole, awsRegion, awsSessionDuration, awsExternalID, awsProfile) } + // For gcloud, pass the CLI flags via context + if providerName == "gcloud" { + ctx = gcloud.WithGrantOptions(ctx, gcloudProject, gcloudScopes, gcloudImpersonateSA, gcloudKeyFile) + } + provCred, err := prov.Grant(ctx) if err != nil { return err diff --git a/docs/content/guides/14-gcloud.md b/docs/content/guides/14-gcloud.md new file mode 100644 index 00000000..8796698c --- /dev/null +++ b/docs/content/guides/14-gcloud.md @@ -0,0 +1,103 @@ +--- +title: "Google Cloud" +navTitle: "Google Cloud" +description: "Use gcloud CLI and Google Cloud client libraries inside a Moat sandbox." +keywords: ["moat", "gcloud", "google cloud", "gcp", "credentials", "metadata server"] +--- + +# Google Cloud + +Moat injects Google Cloud credentials into containers through a GCE metadata server emulator. The gcloud CLI, `google-cloud-*` client libraries, and any tool that uses Application Default Credentials (ADC) work without configuration changes inside the container. + +No long-lived credentials (refresh tokens, service account keys) enter the container. + +## Prerequisites + +Configure Application Default Credentials on the host: + +```bash +gcloud auth application-default login +``` + +Or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account JSON key file. + +## Grant a credential + +```bash +moat grant gcloud --project my-project +``` + +The `--project` flag sets the GCP project ID for API calls. If omitted, Moat checks `GOOGLE_CLOUD_PROJECT`, `CLOUDSDK_CORE_PROJECT`, then `gcloud config get-value project`. + +### Service account impersonation + +```bash +moat grant gcloud \ + --project my-project \ + --impersonate-service-account deploy@my-project.iam.gserviceaccount.com +``` + +The host's credentials are used to impersonate the specified service account via IAM. The container receives tokens scoped to the impersonated identity. + +### Explicit key file + +```bash +moat grant gcloud \ + --project my-project \ + --key-file /path/to/service-account.json +``` + +The key file stays on the host. The daemon reads it to mint access tokens — the file is never mounted into the container. + +### Custom scopes + +```bash +moat grant gcloud \ + --project my-project \ + --scopes https://www.googleapis.com/auth/bigquery,https://www.googleapis.com/auth/cloud-platform +``` + +Use `--scopes` to override the default OAuth2 scope (`https://www.googleapis.com/auth/cloud-platform`) with a comma-separated list of specific scopes. + +## Run with gcloud access + +```bash +moat run --grant gcloud ./my-project +``` + +Or in `moat.yaml`: + +```yaml +grants: + - gcloud +``` + +## How it works + +1. `moat grant gcloud` stores the project ID and credential configuration (not tokens) in the encrypted credential store +2. When a run starts, the proxy daemon creates a metadata server emulator for that run +3. The container's `HTTP_PROXY` routes requests to `metadata.google.internal` through the proxy +4. The proxy intercepts these requests and serves access tokens from the emulator +5. Access tokens are cached and refreshed 5 minutes before expiry + +This is the same pattern Google Cloud uses on GCE instances and Cloud Run — the container behaves as if it is running on Google Cloud infrastructure. + +## Verifying inside the container + +```bash +# Check the metadata server responds +curl -s -H "Metadata-Flavor: Google" \ + http://metadata.google.internal/computeMetadata/v1/project/project-id + +# Fetch an access token +curl -s -H "Metadata-Flavor: Google" \ + http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token + +# Use gcloud normally +gcloud projects list --limit=1 +``` + +## Limitations + +- **ID tokens** (`audience=...` parameter) are not supported. The metadata emulator returns 404 for identity token requests. +- **Workload Identity Federation** from non-GCP external sources works if the host's ADC file is configured for it, but there is no dedicated CLI UX. diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index e02afedd..d7da5625 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -392,6 +392,7 @@ grants: | Format | Description | |--------|-------------| +| `gcloud` | Google Cloud (gcloud CLI + client libraries) | | `github` | GitHub API | | `anthropic` | Anthropic API | | `openai` | OpenAI API | diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index 5a46a90a..70286489 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -2,7 +2,7 @@ title: "Grants reference" navTitle: "Grants" description: "Complete reference for Moat grant types: supported providers, host matching, credential sources, and configuration." -keywords: ["moat", "grants", "credentials", "github", "anthropic", "aws", "ssh", "openai", "npm", "graphite", "meta", "facebook", "instagram", "gitlab", "brave-search", "elevenlabs", "linear", "vercel", "sentry", "datadog"] +keywords: ["moat", "grants", "credentials", "github", "anthropic", "aws", "gcloud", "google cloud", "gcp", "ssh", "openai", "npm", "graphite", "meta", "facebook", "instagram", "gitlab", "brave-search", "elevenlabs", "linear", "vercel", "sentry", "datadog"] --- # Grants reference @@ -24,6 +24,7 @@ Store a credential with `moat grant `, then use it in runs with `--gra | `meta` | `graph.facebook.com`, `graph.instagram.com` | `Authorization: Bearer ...` | `META_ACCESS_TOKEN` or prompt | | `npm` | Per-registry (e.g., `registry.npmjs.org`, `npm.company.com`) | `Authorization: Bearer ...` | `.npmrc`, `NPM_TOKEN`, or manual | | `aws` | All AWS service endpoints | AWS `credential_process` (STS temporary credentials) | IAM role assumption via STS | +| `gcloud` | GCE metadata emulation (not header injection) | Access token via metadata endpoint | Application Default Credentials, service account key, or impersonation | | `ssh:` | Specified host only | SSH agent forwarding (not HTTP) | Host SSH agent (`SSH_AUTH_SOCK`) | | `mcp-` | Host from MCP server `url` field | Configured per-server header | Interactive prompt | | `gitlab` | `gitlab.com`, `*.gitlab.com` | `PRIVATE-TOKEN: ...` | `GITLAB_TOKEN`, `GL_TOKEN`, or prompt | @@ -596,6 +597,82 @@ AWS credential saved $ moat run --grant aws ./my-project ``` +## Google Cloud (gcloud) + +### CLI command + +```bash +moat grant gcloud [flags] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--project ID` | GCP project ID | From `gcloud config get-value project` | +| `--key-file PATH` | Path to a service account JSON key file on the host | -- | +| `--impersonate-service-account EMAIL` | Service account to impersonate via IAM | -- | +| `--scopes SCOPES` | Comma-separated OAuth scopes | `https://www.googleapis.com/auth/cloud-platform` | + +### Credential source + +Moat uses your host's Application Default Credentials to mint short-lived access tokens. Your host must have valid credentials configured via one of: + +1. **`gcloud auth application-default login`** — user OAuth credentials +2. **`GOOGLE_APPLICATION_CREDENTIALS`** — path to a service account JSON key +3. **`--key-file`** — explicit service account key passed at grant time +4. **Service account impersonation** — `--impersonate-service-account` delegates via IAM + +### What it injects + +Google Cloud credentials use metadata server emulation rather than HTTP header injection: + +1. The project ID and configuration are stored at grant time (not access tokens) +2. When a run starts, the proxy serves a GCE metadata server emulator +3. The container's `HTTP_PROXY` routes requests to `metadata.google.internal` through the proxy +4. The gcloud CLI and all Google client libraries fetch access tokens from the emulated metadata endpoint +5. Access tokens are short-lived and refreshed automatically by the proxy daemon + +No long-lived credentials (refresh tokens, service account keys) enter the container. + +### Container environment + +The following environment variables are set in the container: + +| Variable | Value | +|----------|-------| +| `GOOGLE_CLOUD_PROJECT` | Configured project ID | +| `CLOUDSDK_CORE_PROJECT` | Configured project ID | + +### Refresh behavior + +The proxy daemon caches access tokens and refreshes them 5 minutes before expiry. Token refresh is transparent to the container. + +### moat.yaml + +```yaml +grants: + - gcloud +``` + +> **Note:** gcloud-specific options (project, key file, impersonation, scopes) are configured at grant time with `moat grant gcloud`, not in `moat.yaml`. + +### Example + +```bash +$ moat grant gcloud --project my-project + +gcloud credential saved + +$ moat run --grant gcloud ./my-project +``` + +### Troubleshooting + +**"no host credentials found"** — Run `gcloud auth application-default login` on the host, or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file. + +**"refresh token revoked or expired"** — Re-authenticate with `gcloud auth application-default login`. + ## SSH ### CLI command diff --git a/docs/plans/2026-04-08-gcloud-provider-plan.md b/docs/plans/2026-04-08-gcloud-provider-plan.md new file mode 100644 index 00000000..02c6fd62 --- /dev/null +++ b/docs/plans/2026-04-08-gcloud-provider-plan.md @@ -0,0 +1,1540 @@ +# gcloud Provider Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `moat grant gcloud` credential provider that authenticates the `gcloud` CLI and every Google client library inside a Moat sandbox, without leaking refresh tokens or service-account keys into the container. + +**Architecture:** Mirror the AWS provider. The host daemon reads Google credentials from `~/.config/gcloud/application_default_credentials.json` (or a specified SA key / impersonation target), mints short-lived access tokens using `golang.org/x/oauth2/google`, caches them, and serves them via an HTTP endpoint on the proxy that emulates the GCE metadata server. The container is pointed at that endpoint via `GCE_METADATA_HOST`. No long-lived material ever enters the container. + +**Tech Stack:** Go, `golang.org/x/oauth2/google`, existing `internal/provider` + `internal/daemon` + `internal/providers/aws` patterns, existing `internal/deps` gcloud install recipe. + +**Why the AWS pattern, not GitHub/Claude:** gcloud always refreshes its own token locally before making API calls by POSTing to `oauth2.googleapis.com/token`. Pure Authorization-header injection at the TLS proxy is insufficient — we'd have to synthesize OAuth token-response bodies. The GCE metadata server is the native, documented extension point: set `GCE_METADATA_HOST=` and both gcloud and every ADC client library happily fetch bearer tokens from us. This is structurally identical to AWS's ECS credential endpoint. + +--- + +## Background reading (before starting) + +- `/workspace/internal/providers/aws/endpoint.go` — reference shape of a credential endpoint handler (auth token gating, cache with `credentialRefreshBuffer`). +- `/workspace/internal/providers/aws/credential_provider.go` — reference for the host-side credential-fetching component used by the daemon. +- `/workspace/internal/providers/aws/provider.go` — CredentialProvider + EndpointProvider interface implementation, `init()` registration pattern. +- `/workspace/internal/providers/aws/grant.go` — grant UX shape, `WithGrantOptions` context pattern, storing config in `Credential.Metadata`. +- `/workspace/internal/daemon/runcontext.go` lines 39–76, 146–151, 414–416 — how AWS is plumbed into `RunContext` via `AWSConfig` field, `awsHandler` (`http.Handler`) field, and `SetAWSHandler`. We will add `GCloudConfig` and `gcloudHandler` in the same pattern. +- `/workspace/internal/daemon/server.go` lines 230–251 — where the daemon constructs the AWS handler at run-register time. Add parallel block for gcloud. +- `/workspace/internal/daemon/api.go` line 76 — `AWSConfig` field on `RegisterRequest`. We will add `GCloudConfig`. **Must be additive-only: the daemon API is backwards-compatible across binary versions (see `internal/daemon/api.go` package doc).** +- `/workspace/internal/daemon/persist.go` lines 34, 79, 208, 231–248 — daemon persistence: add `GCloudConfig` alongside `AWSConfig`. +- `/workspace/internal/proxy/proxy.go` lines 296, 338, 409–411, 987–1051, 1077–1083, 1107–1111 — how AWS handler is routed (`/_aws/credentials` path and the `/_aws/` direct-request path). We will not use a new proxy path — instead we will add a `GCloudHandler` field on `RunContextData` and route `/computeMetadata/` requests there. +- `/workspace/internal/run/manager.go` lines 660–998 — how AWS is set up per run (provider creation, daemon registration, container env injection, file helper, mount). Mirror for gcloud but **no files mounted**: just env vars. +- `/workspace/internal/providers/gemini/token_refresh.go` — an existing Google OAuth refresher, but uses Gemini's OAuth client, not gcloud's. We will NOT reuse it directly; we will use `golang.org/x/oauth2/google.FindDefaultCredentials` which handles user OAuth, SA keys, impersonation, and federation uniformly. +- `/workspace/internal/deps/registry.yaml` line 241 — `gcloud` dependency already exists. `ImpliedDependencies()` should return `["gcloud"]`. +- Google docs on the metadata server endpoints (for emulation): `https://cloud.google.com/compute/docs/metadata/default-metadata-values` and `https://cloud.google.com/compute/docs/metadata/overriding-metadata`. + +## Relevant skills + +- @superpowers:test-driven-development — required for every step below that writes code. +- @superpowers:verification-before-completion — required before committing each task. + +## Scope + +**In scope:** +- New `internal/providers/gcloud/` package. +- Daemon plumbing (`RunContext.GCloudConfig`, `gcloudHandler`, API field, persist field). +- Proxy routing for `/computeMetadata/` paths. +- Run manager wiring (container env vars). +- Tests for grant parsing, credential minting (with mocked token source), endpoint handler, and proxy routing. +- Documentation: `docs/content/reference/02-moat-yaml.md` and a new `docs/content/guides/` page for `moat grant gcloud`. +- CHANGELOG entry. + +**Out of scope:** +- Per-request credential scoping by Google API service. +- Workload Identity Federation from non-GCP external sources (supported automatically if the host's ADC file is an `external_account` config, but we won't build CLI UX for it). +- Emulating the full metadata server (only the subset gcloud + ADC libraries need). +- Changes to the `gcloud` install recipe. + +## File Structure + +**Create:** +- `internal/providers/gcloud/doc.go` — package docs. +- `internal/providers/gcloud/provider.go` — `Provider` implementing `CredentialProvider` + `EndpointProvider`, `init()` registration. +- `internal/providers/gcloud/grant.go` — grant flow, config parsing, `WithGrantOptions`, `Config` struct, `ConfigFromCredential`. +- `internal/providers/gcloud/credential_provider.go` — host-side `CredentialProvider` struct with `GetToken(ctx)` cached token source. Internally wraps `google.FindDefaultCredentials` or a `credentials.json` file path. +- `internal/providers/gcloud/endpoint.go` — `http.Handler` that serves the metadata emulation routes. +- `internal/providers/gcloud/endpoint_test.go` +- `internal/providers/gcloud/grant_test.go` +- `internal/providers/gcloud/credential_provider_test.go` +- `internal/providers/gcloud/provider_test.go` +- `docs/content/guides/XX-gcloud.md` — user-facing guide. + +**Modify:** +- `internal/providers/register.go` — add blank import of gcloud package. +- `internal/daemon/runcontext.go` — add `GCloudConfig *GCloudConfig` field (JSON tag `gcloud_config,omitempty`), `gcloudHandler http.Handler` field, `SetGCloudHandler` method, and wire into `ToProxyContextData()`. +- `internal/daemon/api.go` — add `GCloudConfig *GCloudConfig` field to `RegisterRequest` (additive only), copy to `RunContext` in `handleRegister`. **Document backwards-compat impact in the package doc comment.** +- `internal/daemon/server.go` — add block mirroring lines 230–251 that constructs `gcloudprov.NewCredentialProvider(...)` and calls `rc.SetGCloudHandler(...)`. +- `internal/daemon/persist.go` — add `GCloudConfig` to `persistedRun`, copy into/out of `RunContext`, reconstruct handler on daemon restart. +- `internal/daemon/persist_test.go` — round-trip test for `GCloudConfig`. +- `internal/daemon/api_test.go` — API request/response round-trip for `GCloudConfig`. +- `internal/proxy/proxy.go` — add `GCloudHandler http.Handler` to `RunContextData`, field on `Proxy`, `SetGCloudHandler`, `getGCloudHandlerForRequest`, and route `/computeMetadata/` paths to it in `ServeHTTP`. Mirror the `/_aws/` direct-request path for containers that reach the handler via `GCE_METADATA_HOST`. +- `internal/run/manager.go` — in the section that configures provider endpoints per run, add gcloud block that sets `GCE_METADATA_HOST=`, `GCE_METADATA_IP=`, `GOOGLE_CLOUD_PROJECT=`, `CLOUDSDK_CORE_PROJECT=`, `CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE=true` (so gcloud itself uses metadata), and includes the per-run auth token if needed. +- `internal/run/run.go` — add `GCloudCredentialProvider *gcloudprov.CredentialProvider` field alongside `AWSCredentialProvider`. +- `docs/content/reference/02-moat-yaml.md` — document the new grant. +- `CHANGELOG.md` — add Added entry under next release, link to PR. +- `go.mod` / `go.sum` — `golang.org/x/oauth2` (likely already present transitively via AWS SDK; verify). + +## Design decisions locked in + +1. **Token source:** Use `golang.org/x/oauth2/google.FindDefaultCredentials(ctx, scopes...)` for the default path. This transparently handles user OAuth refresh, service account JWT-bearer, impersonation, and federation. For the MVP we accept whatever ADC the host has configured. +2. **Scopes:** Request `https://www.googleapis.com/auth/cloud-platform` by default. Allow override in grant metadata `scopes`. +3. **Project ID:** Required. Read from `gcloud config get-value project` at grant time, or accept `--project` flag. Store in credential metadata. The metadata server endpoint returns it to clients; some libraries require it. +4. **Endpoint auth:** The metadata server does not use bearer auth — but containers reach our handler via the proxy's per-run context resolution (same mechanism AWS uses for `/_aws/credentials` direct requests). We additionally require the `Metadata-Flavor: Google` header on every request, matching real metadata server behavior and blocking accidental DNS-rebinding hits. +5. **No files in container.** The container gets env vars only. This is strictly better than AWS's model (which writes a helper script and config file) and is possible because Google's stack natively supports the `GCE_METADATA_HOST` override. +6. **Token caching:** Same shape as AWS — cache in the `CredentialProvider`, refresh `credentialRefreshBuffer` (5 min) before expiry, under a mutex. Reuse the constant from `aws` package? No — duplicate it locally to avoid coupling. +7. **Metadata endpoints to implement (minimum viable set):** + - `GET /computeMetadata/v1/instance/service-accounts/default/token` → `{"access_token":"…","expires_in":,"token_type":"Bearer"}` + - `GET /computeMetadata/v1/instance/service-accounts/default/email` → SA email (from credentials; synthesized as `user@moat.local` for user OAuth) + - `GET /computeMetadata/v1/instance/service-accounts/default/scopes` → newline-separated scope list + - `GET /computeMetadata/v1/instance/service-accounts/default/aliases` → `default` + - `GET /computeMetadata/v1/instance/service-accounts/default/` → `aliases\nemail\nidentity\nscopes\ntoken\n` + - `GET /computeMetadata/v1/instance/service-accounts/` → `default/\n/\n` + - `GET /computeMetadata/v1/project/project-id` → the configured project ID + - `GET /computeMetadata/v1/project/numeric-project-id` → `0` (most libs accept this) + - `GET /` and `GET /computeMetadata/` → 200 with `Metadata-Flavor: Google` header for liveness probes + - All responses MUST include `Metadata-Flavor: Google` header. All requests MUST have `Metadata-Flavor: Google` header or we return 403. + - ID token endpoint (`.../identity?audience=…`) is deferred; return 404. + +--- + +## Tasks + +### Task 1: Package skeleton + provider registration + +**Files:** +- Create: `internal/providers/gcloud/doc.go` +- Create: `internal/providers/gcloud/provider.go` +- Create: `internal/providers/gcloud/provider_test.go` +- Modify: `internal/providers/register.go` + +- [ ] **Step 1: Write failing test for provider name and registration** + +```go +// provider_test.go +package gcloud + +import ( + "testing" + "github.com/majorcontext/moat/internal/provider" +) + +func TestProviderName(t *testing.T) { + p := New() + if p.Name() != "gcloud" { + t.Errorf("Name() = %q, want %q", p.Name(), "gcloud") + } +} + +func TestProviderRegistered(t *testing.T) { + if _, ok := provider.Get("gcloud"); !ok { + t.Error("gcloud provider not registered") + } +} + +func TestImpliedDependencies(t *testing.T) { + p := New() + deps := p.ImpliedDependencies() + if len(deps) != 1 || deps[0] != "gcloud" { + t.Errorf("ImpliedDependencies() = %v, want [gcloud]", deps) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/providers/gcloud/...` +Expected: build failure (package does not exist). + +- [ ] **Step 3: Create doc.go** + +```go +// Package gcloud implements a credential provider for Google Cloud. +// +// Unlike header-injection providers (GitHub, Claude), gcloud follows the +// AWS model: the host daemon mints short-lived access tokens using the +// host's Application Default Credentials and serves them to the container +// via a GCE metadata server emulator. The container is pointed at the +// emulator via GCE_METADATA_HOST. No long-lived credentials enter the +// container. +package gcloud +``` + +- [ ] **Step 4: Create minimal provider.go** + +```go +package gcloud + +import ( + "context" + "net/http" + + "github.com/majorcontext/moat/internal/provider" +) + +type Provider struct{} + +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.EndpointProvider = (*Provider)(nil) +) + +func New() *Provider { return &Provider{} } + +func init() { provider.Register(New()) } + +func (p *Provider) Name() string { return "gcloud" } + +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + return grant(ctx) +} + +func (p *Provider) ConfigureProxy(pc provider.ProxyConfigurer, cred *provider.Credential) { + // No-op: gcloud uses metadata endpoint, not header injection. +} + +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + // Env vars are injected by the run manager since they depend on the + // per-run proxy host:port. + return nil +} + +func (p *Provider) ContainerMounts(cred *provider.Credential, containerHome string) ([]provider.MountConfig, string, error) { + return nil, "", nil +} + +func (p *Provider) Cleanup(cleanupPath string) {} + +func (p *Provider) ImpliedDependencies() []string { return []string{"gcloud"} } + +func (p *Provider) RegisterEndpoints(mux *http.ServeMux, cred *provider.Credential) { + // Metadata emulation is served by a per-run handler attached to the + // RunContext at daemon-register time, not via this package-level mux. +} +``` + +- [ ] **Step 5: Add blank import to `internal/providers/register.go`** + +Add line in the import block: + +```go +_ "github.com/majorcontext/moat/internal/providers/gcloud" // registers gcloud provider +``` + +Maintain alphabetical order. + +- [ ] **Step 6: Add temporary stub grant function** + +Create `internal/providers/gcloud/grant.go`: + +```go +package gcloud + +import ( + "context" + "errors" + + "github.com/majorcontext/moat/internal/provider" +) + +func grant(ctx context.Context) (*provider.Credential, error) { + return nil, errors.New("gcloud grant: not yet implemented") +} +``` + +- [ ] **Step 7: Run tests** + +Run: `go test ./internal/providers/gcloud/...` +Expected: PASS (3 tests). + +- [ ] **Step 8: Run full build** + +Run: `go build ./...` +Expected: no errors. + +- [ ] **Step 9: Commit** + +```bash +git add internal/providers/gcloud/ internal/providers/register.go +git commit -m "feat(gcloud): add provider skeleton and registration" +``` + +### Task 2: Config + grant parsing + +**Files:** +- Modify: `internal/providers/gcloud/grant.go` +- Create/extend: `internal/providers/gcloud/grant_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +// grant_test.go +package gcloud + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func TestConfigFromCredential(t *testing.T) { + cred := &provider.Credential{ + Provider: "gcloud", + Token: "", // gcloud has no primary token; project is in metadata + Metadata: map[string]string{ + MetaKeyProject: "my-proj", + MetaKeyScopes: "https://www.googleapis.com/auth/cloud-platform", + MetaKeyImpersonate: "sa@my-proj.iam.gserviceaccount.com", + MetaKeyKeyFile: "", + }, + } + cfg, err := ConfigFromCredential(cred) + if err != nil { + t.Fatalf("ConfigFromCredential: %v", err) + } + if cfg.ProjectID != "my-proj" { + t.Errorf("ProjectID = %q", cfg.ProjectID) + } + if cfg.ImpersonateSA != "sa@my-proj.iam.gserviceaccount.com" { + t.Errorf("ImpersonateSA = %q", cfg.ImpersonateSA) + } + if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/cloud-platform" { + t.Errorf("Scopes = %v", cfg.Scopes) + } +} + +func TestConfigFromCredentialDefaultScope(t *testing.T) { + cred := &provider.Credential{ + Provider: "gcloud", + Metadata: map[string]string{MetaKeyProject: "p"}, + } + cfg, _ := ConfigFromCredential(cred) + if len(cfg.Scopes) == 0 { + t.Error("expected default scope when none specified") + } +} + +func TestConfigFromCredentialMissingProject(t *testing.T) { + cred := &provider.Credential{Provider: "gcloud", Metadata: map[string]string{}} + _, err := ConfigFromCredential(cred) + if err == nil { + t.Error("expected error when project is missing") + } +} +``` + +- [ ] **Step 2: Run — expected to fail (constants/types don't exist)** + +Run: `go test ./internal/providers/gcloud/ -run TestConfig` +Expected: FAIL to compile. + +- [ ] **Step 3: Implement grant.go** + +```go +package gcloud + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/majorcontext/moat/internal/provider" + "github.com/majorcontext/moat/internal/provider/util" +) + +const ( + MetaKeyProject = "project" + MetaKeyScopes = "scopes" + MetaKeyImpersonate = "impersonate_service_account" + MetaKeyKeyFile = "key_file" + MetaKeyEmail = "email" +) + +const DefaultScope = "https://www.googleapis.com/auth/cloud-platform" + +type ctxKey string + +const ( + ctxKeyProject ctxKey = "gcloud_project" + ctxKeyScopes ctxKey = "gcloud_scopes" + ctxKeyImpersonate ctxKey = "gcloud_impersonate" + ctxKeyKeyFile ctxKey = "gcloud_key_file" +) + +func WithGrantOptions(ctx context.Context, project, scopes, impersonate, keyFile string) context.Context { + ctx = context.WithValue(ctx, ctxKeyProject, project) + ctx = context.WithValue(ctx, ctxKeyScopes, scopes) + ctx = context.WithValue(ctx, ctxKeyImpersonate, impersonate) + ctx = context.WithValue(ctx, ctxKeyKeyFile, keyFile) + return ctx +} + +type Config struct { + ProjectID string + Scopes []string + ImpersonateSA string + KeyFile string // Path to a service account JSON key file on the host + Email string // Best-effort service account / principal email for metadata endpoint +} + +func grant(ctx context.Context) (*provider.Credential, error) { + cfg := &Config{Scopes: []string{DefaultScope}} + + if v, _ := ctx.Value(ctxKeyProject).(string); v != "" { + cfg.ProjectID = v + } + if v, _ := ctx.Value(ctxKeyScopes).(string); v != "" { + cfg.Scopes = splitScopes(v) + } + if v, _ := ctx.Value(ctxKeyImpersonate).(string); v != "" { + cfg.ImpersonateSA = v + } + if v, _ := ctx.Value(ctxKeyKeyFile).(string); v != "" { + cfg.KeyFile = v + } + + if cfg.ProjectID == "" { + // Try `gcloud config get-value project` on the host. + cfg.ProjectID = detectProject() + } + if cfg.ProjectID == "" { + p, err := util.PromptForToken("Enter GCP project ID") + if err != nil { + return nil, &provider.GrantError{ + Provider: "gcloud", + Cause: err, + Hint: "Set with --project or run `gcloud config set project ` on the host.", + } + } + cfg.ProjectID = p + } + + // Validate: the daemon will also do this at runtime, but catch misconfig early. + if err := testCredentials(ctx, cfg); err != nil { + return nil, &provider.GrantError{ + Provider: "gcloud", + Cause: err, + Hint: "Ensure Application Default Credentials are configured on the host:\n" + + " gcloud auth application-default login\n" + + "Or pass --key-file with a service account JSON key.\n" + + "See: https://majorcontext.com/moat/guides/gcloud", + } + } + + cred := &provider.Credential{ + Provider: "gcloud", + Token: "", + CreatedAt: time.Now(), + Metadata: map[string]string{ + MetaKeyProject: cfg.ProjectID, + MetaKeyScopes: strings.Join(cfg.Scopes, " "), + }, + } + if cfg.ImpersonateSA != "" { + cred.Metadata[MetaKeyImpersonate] = cfg.ImpersonateSA + } + if cfg.KeyFile != "" { + cred.Metadata[MetaKeyKeyFile] = cfg.KeyFile + } + if cfg.Email != "" { + cred.Metadata[MetaKeyEmail] = cfg.Email + } + return cred, nil +} + +func splitScopes(s string) []string { + parts := strings.FieldsFunc(s, func(r rune) bool { return r == ' ' || r == ',' }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return []string{DefaultScope} + } + return out +} + +func detectProject() string { + if v := os.Getenv("GOOGLE_CLOUD_PROJECT"); v != "" { + return v + } + if v := os.Getenv("CLOUDSDK_CORE_PROJECT"); v != "" { + return v + } + out, err := exec.Command("gcloud", "config", "get-value", "project").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func ConfigFromCredential(cred *provider.Credential) (*Config, error) { + if cred == nil { + return nil, fmt.Errorf("credential is nil") + } + cfg := &Config{} + if cred.Metadata != nil { + cfg.ProjectID = cred.Metadata[MetaKeyProject] + if s := cred.Metadata[MetaKeyScopes]; s != "" { + cfg.Scopes = splitScopes(s) + } + cfg.ImpersonateSA = cred.Metadata[MetaKeyImpersonate] + cfg.KeyFile = cred.Metadata[MetaKeyKeyFile] + cfg.Email = cred.Metadata[MetaKeyEmail] + } + if len(cfg.Scopes) == 0 { + cfg.Scopes = []string{DefaultScope} + } + if cfg.ProjectID == "" { + return nil, fmt.Errorf("gcloud credential is missing project ID") + } + return cfg, nil +} + +// testCredentials is filled in Task 3 once CredentialProvider exists. +// For now, just return nil so the grant flow is testable. +func testCredentials(ctx context.Context, cfg *Config) error { return nil } +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/providers/gcloud/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/providers/gcloud/grant.go internal/providers/gcloud/grant_test.go +git commit -m "feat(gcloud): parse grant config and credential metadata" +``` + +### Task 3: Host-side CredentialProvider using oauth2/google + +**Files:** +- Create: `internal/providers/gcloud/credential_provider.go` +- Create: `internal/providers/gcloud/credential_provider_test.go` +- Modify: `go.mod`, `go.sum` (likely adds `golang.org/x/oauth2/google`) + +- [ ] **Step 1: Write failing test using a fake token source** + +```go +// credential_provider_test.go +package gcloud + +import ( + "context" + "testing" + "time" + + "golang.org/x/oauth2" +) + +type fakeTokenSource struct { + tok *oauth2.Token + err error + hits int +} + +func (f *fakeTokenSource) Token() (*oauth2.Token, error) { + f.hits++ + return f.tok, f.err +} + +func TestCredentialProviderReturnsToken(t *testing.T) { + exp := time.Now().Add(1 * time.Hour) + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "ya29.fake", Expiry: exp}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + tok, err := p.GetToken(context.Background()) + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.AccessToken != "ya29.fake" { + t.Errorf("AccessToken = %q", tok.AccessToken) + } +} + +func TestCredentialProviderCaches(t *testing.T) { + exp := time.Now().Add(1 * time.Hour) + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: exp}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + for i := 0; i < 5; i++ { + _, _ = p.GetToken(context.Background()) + } + if fts.hits > 1 { + t.Errorf("expected caching, token source hit %d times", fts.hits) + } +} + +func TestCredentialProviderRefreshesOnExpiry(t *testing.T) { + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: time.Now().Add(1 * time.Minute)}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + _, _ = p.GetToken(context.Background()) + _, _ = p.GetToken(context.Background()) + if fts.hits < 2 { + t.Errorf("expected refresh within buffer window, hits=%d", fts.hits) + } +} +``` + +- [ ] **Step 2: Run — should fail to compile** + +Run: `go test ./internal/providers/gcloud/ -run TestCredentialProvider` +Expected: build failure. + +- [ ] **Step 3: Implement credential_provider.go** + +```go +package gcloud + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/impersonate" + "google.golang.org/api/option" +) + +// credentialRefreshBuffer is the time before expiration when the cached +// access token is considered stale and must be refreshed. +const credentialRefreshBuffer = 5 * time.Minute + +// CredentialProvider wraps an oauth2.TokenSource with caching and metadata +// for serving via the metadata emulation endpoint. +type CredentialProvider struct { + cfg *Config + source oauth2.TokenSource + + mu sync.Mutex + cached *oauth2.Token +} + +// NewCredentialProvider builds a CredentialProvider from host-side config. +// It honors (in order): explicit service-account key file, impersonation +// target over ADC source, and plain Application Default Credentials. +func NewCredentialProvider(ctx context.Context, cfg *Config) (*CredentialProvider, error) { + source, err := buildTokenSource(ctx, cfg) + if err != nil { + return nil, err + } + return &CredentialProvider{cfg: cfg, source: source}, nil +} + +// NewCredentialProviderFromTokenSource is used by tests to inject a fake. +func NewCredentialProviderFromTokenSource(ts oauth2.TokenSource, cfg *Config) *CredentialProvider { + return &CredentialProvider{cfg: cfg, source: ts} +} + +func buildTokenSource(ctx context.Context, cfg *Config) (oauth2.TokenSource, error) { + if cfg.KeyFile != "" { + data, err := os.ReadFile(cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("reading key file: %w", err) + } + creds, err := google.CredentialsFromJSON(ctx, data, cfg.Scopes...) + if err != nil { + return nil, fmt.Errorf("parsing key file: %w", err) + } + if cfg.ImpersonateSA != "" { + return impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: cfg.ImpersonateSA, + Scopes: cfg.Scopes, + }, option.WithCredentials(creds)) + } + return creds.TokenSource, nil + } + + if cfg.ImpersonateSA != "" { + return impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: cfg.ImpersonateSA, + Scopes: cfg.Scopes, + }) + } + + creds, err := google.FindDefaultCredentials(ctx, cfg.Scopes...) + if err != nil { + return nil, fmt.Errorf("finding application default credentials: %w", err) + } + return creds.TokenSource, nil +} + +// GetToken returns a cached access token, refreshing if near expiry. +func (p *CredentialProvider) GetToken(ctx context.Context) (*oauth2.Token, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + p.mu.Lock() + defer p.mu.Unlock() + if p.cached != nil && time.Until(p.cached.Expiry) > credentialRefreshBuffer { + return p.cached, nil + } + tok, err := p.source.Token() + if err != nil { + return nil, fmt.Errorf("fetching gcloud token: %w", err) + } + p.cached = tok + return tok, nil +} + +// ProjectID returns the configured GCP project ID. +func (p *CredentialProvider) ProjectID() string { return p.cfg.ProjectID } + +// Scopes returns the configured scopes. +func (p *CredentialProvider) Scopes() []string { return p.cfg.Scopes } + +// Email returns a best-effort principal email for the metadata endpoint. +// Returns "default@moat.local" if unknown. +func (p *CredentialProvider) Email() string { + if p.cfg.Email != "" { + return p.cfg.Email + } + return "default@moat.local" +} +``` + +- [ ] **Step 4: Wire testCredentials() in grant.go** + +Replace the stub with: + +```go +func testCredentials(ctx context.Context, cfg *Config) error { + p, err := NewCredentialProvider(ctx, cfg) + if err != nil { + return err + } + _, err = p.GetToken(ctx) + return err +} +``` + +- [ ] **Step 5: Tidy modules** + +Run: `go mod tidy` +Expected: adds `golang.org/x/oauth2` (may already be present), `google.golang.org/api/impersonate`, `google.golang.org/api/option`. + +- [ ] **Step 6: Run tests** + +Run: `go test ./internal/providers/gcloud/...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/providers/gcloud/ go.mod go.sum +git commit -m "feat(gcloud): host-side credential provider with token caching" +``` + +### Task 4: Metadata emulation HTTP handler + +**Files:** +- Create: `internal/providers/gcloud/endpoint.go` +- Create: `internal/providers/gcloud/endpoint_test.go` + +- [ ] **Step 1: Write failing tests for every endpoint** + +```go +// endpoint_test.go +package gcloud + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func newTestHandler(t *testing.T) *EndpointHandler { + t.Helper() + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "ya29.x", Expiry: time.Now().Add(time.Hour)}} + cp := NewCredentialProviderFromTokenSource(fts, &Config{ + ProjectID: "test-proj", + Scopes: []string{DefaultScope}, + }) + return NewEndpointHandler(cp) +} + +func doReq(h http.Handler, path string, withFlavor bool) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + if withFlavor { + r.Header.Set("Metadata-Flavor", "Google") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestMetadataRequiresFlavorHeader(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/token", false) + if w.Code != http.StatusForbidden { + t.Errorf("missing Metadata-Flavor: got %d, want 403", w.Code) + } +} + +func TestMetadataToken(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/token", true) + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body.String()) + } + if w.Header().Get("Metadata-Flavor") != "Google" { + t.Error("missing response Metadata-Flavor header") + } + var body struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.AccessToken != "ya29.x" || body.TokenType != "Bearer" || body.ExpiresIn <= 0 { + t.Errorf("unexpected body: %+v", body) + } +} + +func TestMetadataProjectID(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/project/project-id", true) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != "test-proj" { + t.Errorf("project-id: code=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestMetadataNumericProjectID(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/project/numeric-project-id", true) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != "0" { + t.Errorf("numeric-project-id: code=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestMetadataScopes(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/scopes", true) + if w.Code != 200 || !strings.Contains(w.Body.String(), DefaultScope) { + t.Errorf("scopes: code=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestMetadataEmail(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/email", true) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != "default@moat.local" { + t.Errorf("email: code=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestMetadataServiceAccountsDirListing(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/", true) + body, _ := io.ReadAll(w.Body) + if w.Code != 200 || !strings.Contains(string(body), "token") { + t.Errorf("dir listing: code=%d body=%q", w.Code, body) + } +} + +func TestMetadataLiveness(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/", true) + if w.Code != 200 { + t.Errorf("liveness: code=%d", w.Code) + } + if w.Header().Get("Metadata-Flavor") != "Google" { + t.Error("missing Metadata-Flavor on liveness") + } +} + +func TestMetadataIdentityNotImplemented(t *testing.T) { + h := newTestHandler(t) + w := doReq(h, "/computeMetadata/v1/instance/service-accounts/default/identity?audience=x", true) + if w.Code != http.StatusNotFound { + t.Errorf("identity: code=%d, want 404", w.Code) + } +} + +func TestMetadataTokenContextCancel(t *testing.T) { + h := newTestHandler(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := httptest.NewRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/token", nil).WithContext(ctx) + r.Header.Set("Metadata-Flavor", "Google") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code == 200 { + t.Error("expected error on canceled context") + } +} +``` + +- [ ] **Step 2: Run tests — should fail to compile** + +Run: `go test ./internal/providers/gcloud/ -run TestMetadata` +Expected: FAIL to compile (no EndpointHandler). + +- [ ] **Step 3: Implement endpoint.go** + +```go +package gcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/majorcontext/moat/internal/log" +) + +// EndpointHandler serves the subset of the GCE metadata server required by +// gcloud and the Google client libraries. All responses include +// `Metadata-Flavor: Google` and all requests must include it. +type EndpointHandler struct { + cp *CredentialProvider +} + +func NewEndpointHandler(cp *CredentialProvider) *EndpointHandler { + return &EndpointHandler{cp: cp} +} + +func (h *EndpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Metadata-Flavor") != "Google" { + http.Error(w, "Metadata-Flavor header required", http.StatusForbidden) + return + } + w.Header().Set("Metadata-Flavor", "Google") + + path := r.URL.Path + + switch { + case path == "/" || path == "/computeMetadata/" || path == "/computeMetadata/v1/": + w.WriteHeader(http.StatusOK) + return + + case path == "/computeMetadata/v1/instance/service-accounts/default/token": + h.serveToken(w, r) + + case path == "/computeMetadata/v1/instance/service-accounts/default/email": + fmt.Fprintln(w, h.cp.Email()) + + case path == "/computeMetadata/v1/instance/service-accounts/default/scopes": + for _, s := range h.cp.Scopes() { + fmt.Fprintln(w, s) + } + + case path == "/computeMetadata/v1/instance/service-accounts/default/aliases": + fmt.Fprintln(w, "default") + + case path == "/computeMetadata/v1/instance/service-accounts/default/": + fmt.Fprint(w, "aliases\nemail\nidentity\nscopes\ntoken\n") + + case path == "/computeMetadata/v1/instance/service-accounts/": + fmt.Fprintf(w, "default/\n%s/\n", h.cp.Email()) + + case path == "/computeMetadata/v1/project/project-id": + fmt.Fprint(w, h.cp.ProjectID()) + + case path == "/computeMetadata/v1/project/numeric-project-id": + fmt.Fprint(w, "0") + + case strings.HasPrefix(path, "/computeMetadata/v1/instance/service-accounts/default/identity"): + // ID tokens not yet supported. + http.Error(w, "identity tokens not supported by moat metadata emulator", http.StatusNotFound) + + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (h *EndpointHandler) serveToken(w http.ResponseWriter, r *http.Request) { + tok, err := h.cp.GetToken(r.Context()) + if err != nil { + log.Error("gcloud token fetch error", "error", err) + msg := classifyError(err) + http.Error(w, msg, http.StatusInternalServerError) + return + } + + expiresIn := int(time.Until(tok.Expiry).Seconds()) + if expiresIn < 0 { + expiresIn = 0 + } + resp := map[string]any{ + "access_token": tok.AccessToken, + "expires_in": expiresIn, + "token_type": "Bearer", + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Warn("failed to encode gcloud token response", "error", err) + } +} + +func classifyError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "could not find default credentials"): + return `gcloud credential error: no host credentials found + +The moat daemon cannot find Google Cloud credentials on the host. +Ensure one of these is configured: + - gcloud auth application-default login + - GOOGLE_APPLICATION_CREDENTIALS= + - A service account key passed via 'moat grant gcloud --key-file' + +Run 'gcloud auth application-default print-access-token' on your host to verify.` + case strings.Contains(msg, "invalid_grant"): + return `gcloud credential error: refresh token revoked or expired + +Re-authenticate on the host: + gcloud auth application-default login` + case strings.Contains(msg, "context deadline exceeded") || strings.Contains(msg, "context canceled"): + return "gcloud credential error: request canceled or timed out." + default: + return "gcloud credential error: see daemon log at ~/.moat/debug/daemon.log" + } +} + +// Compile-time check that the handler implements http.Handler. +var _ http.Handler = (*EndpointHandler)(nil) + +// Silence unused import if context is not directly referenced in some builds. +var _ = context.Background +``` + +- [ ] **Step 4: Run the full test file** + +Run: `go test ./internal/providers/gcloud/...` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/providers/gcloud/endpoint.go internal/providers/gcloud/endpoint_test.go +git commit -m "feat(gcloud): metadata server emulation handler" +``` + +### Task 5: Daemon plumbing + +**Files:** +- Modify: `internal/daemon/runcontext.go` +- Modify: `internal/daemon/api.go` +- Modify: `internal/daemon/server.go` +- Modify: `internal/daemon/persist.go` +- Modify: `internal/daemon/persist_test.go` +- Modify: `internal/daemon/api_test.go` + +- [ ] **Step 1: Read the current `api.go` package doc about backwards compatibility** + +Run: `Read internal/daemon/api.go` lines 1–40. +Confirm: additive-only rule for API fields. + +- [ ] **Step 2: Write failing test for RunContext round-trip** + +In `internal/daemon/persist_test.go`, after the AWSConfig test block, add: + +```go +func TestGCloudConfigPersist(t *testing.T) { + rc := NewRunContext("run-gcloud") + rc.GCloudConfig = &GCloudConfig{ + ProjectID: "p", + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + } + // ... round-trip via the persister, mirroring existing AWSConfig test +} +``` + +Fill in the body by copying the existing `AWSConfig` test shape from lines 20-90. + +- [ ] **Step 3: Write failing test for API round-trip** + +In `internal/daemon/api_test.go`, add a gcloud parallel to the AWSConfig test around line 120. + +- [ ] **Step 4: Run — should fail to compile** + +Run: `go test ./internal/daemon/... -run GCloud` +Expected: FAIL. + +- [ ] **Step 5: Add `GCloudConfig` type and plumbing to `runcontext.go`** + +Add after `AWSConfig` struct (~line 46): + +```go +// GCloudConfig holds Google Cloud credential provider configuration. +// All long-lived credential material stays on the host; the container +// only sees short-lived access tokens served via metadata emulation. +type GCloudConfig struct { + ProjectID string `json:"project_id"` + Scopes []string `json:"scopes,omitempty"` + ImpersonateSA string `json:"impersonate_service_account,omitempty"` + KeyFile string `json:"key_file,omitempty"` + Email string `json:"email,omitempty"` +} +``` + +Add to `RunContext` struct near `AWSConfig`: + +```go +GCloudConfig *GCloudConfig `json:"gcloud_config,omitempty"` +``` + +Add private field next to `awsHandler`: + +```go +gcloudHandler http.Handler `json:"-"` +``` + +Add method mirroring `SetAWSHandler`: + +```go +// SetGCloudHandler stores the gcloud metadata endpoint handler for this run. +func (rc *RunContext) SetGCloudHandler(h http.Handler) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.gcloudHandler = h +} +``` + +In `ToProxyContextData()`, near line 415 where `d.AWSHandler = rc.awsHandler`, add: + +```go +d.GCloudHandler = rc.gcloudHandler +``` + +- [ ] **Step 6: Add API field** + +In `internal/daemon/api.go` near `AWSConfig` (~line 76), add: + +```go +GCloudConfig *GCloudConfig `json:"gcloud_config,omitempty"` +``` + +In the handler (`handleRegister`), next to `rc.AWSConfig = req.AWSConfig` (~line 146), add: + +```go +rc.GCloudConfig = req.GCloudConfig +``` + +Update the package doc to note that `GCloudConfig` is the new additive field. + +- [ ] **Step 7: Add daemon server construction** + +In `internal/daemon/server.go`, after the AWS block (after line 251), add: + +```go +if req.GCloudConfig != nil { + gcloudCfg := &gcloudprov.Config{ + ProjectID: req.GCloudConfig.ProjectID, + Scopes: req.GCloudConfig.Scopes, + ImpersonateSA: req.GCloudConfig.ImpersonateSA, + KeyFile: req.GCloudConfig.KeyFile, + Email: req.GCloudConfig.Email, + } + cp, err := gcloudprov.NewCredentialProvider(runCtx, gcloudCfg) + if err != nil { + log.Warn("failed to create gcloud credential provider for run", + "run_id", rc.RunID, "error", err) + } else { + rc.SetGCloudHandler(gcloudprov.NewEndpointHandler(cp)) + } +} +``` + +Add import at the top of the file: + +```go +gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" +``` + +- [ ] **Step 8: Add persistence** + +In `internal/daemon/persist.go`: + +- Line 34: add `GCloudConfig *GCloudConfig \`json:"gcloud_config,omitempty"\`` to `persistedRun`. +- Line 79: in the save loop, add `GCloudConfig: rc.GCloudConfig,`. +- Line 208: in restore, add `rc.GCloudConfig = pr.GCloudConfig`. +- After line 248 (the AWS handler reconstruction block), add a parallel block that rebuilds the gcloud handler via `gcloudprov.NewCredentialProvider(...)` + `NewEndpointHandler(...)`. + +- [ ] **Step 9: Run tests** + +Run: `go test ./internal/daemon/...` +Expected: PASS including the new GCloud tests. + +- [ ] **Step 10: Commit** + +```bash +git add internal/daemon/ +git commit -m "feat(gcloud): daemon run context and persistence plumbing" +``` + +### Task 6: Proxy routing for /computeMetadata/ + +**Files:** +- Modify: `internal/proxy/proxy.go` +- Create test: inline in existing `internal/proxy/mcp_regression_test.go` or new `proxy_gcloud_test.go`. + +- [ ] **Step 1: Write failing test** + +Create `internal/proxy/proxy_gcloud_test.go`: + +```go +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGCloudMetadataRouting(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(200) + }) + + p, err := New(Config{ + Port: 0, + GCloudHandler: mock, + }) + if err != nil { + t.Fatal(err) + } + + // Simulate a direct request to /computeMetadata/... from inside the container. + req := httptest.NewRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/token", nil) + req.Header.Set("Metadata-Flavor", "Google") + req.URL.Host = "" // direct, not proxied + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if !called { + t.Error("expected gcloud handler to be called, got %d", w.Code) + } +} +``` + +Note: the test uses the `Proxy` config shape that exists today (`AWSHandler` on the Config struct at line 296). Follow the exact same pattern. + +- [ ] **Step 2: Run the test — expected to fail** + +Run: `go test ./internal/proxy/ -run TestGCloudMetadata` +Expected: FAIL to compile (no `GCloudHandler` field). + +- [ ] **Step 3: Add `GCloudHandler` to `Config`** + +In `internal/proxy/proxy.go` near line 296: + +```go +GCloudHandler http.Handler +``` + +- [ ] **Step 4: Add field to `Proxy` struct (~line 338)** + +```go +gcloudHandler http.Handler // Optional handler for gcloud metadata emulation +``` + +Initialize in the `New()` constructor next to `awsHandler: cfg.AWSHandler,`. + +- [ ] **Step 5: Add `SetGCloudHandler` method (~line 411)** + +```go +func (p *Proxy) SetGCloudHandler(h http.Handler) { + p.gcloudHandler = h +} +``` + +- [ ] **Step 6: Add `GCloudHandler` to `RunContextData`** + +Locate the struct (it's the type used in `getRunContext(r)`). Add: + +```go +GCloudHandler http.Handler +``` + +- [ ] **Step 7: Add getter helper (near `getAWSHandlerForRequest` ~line 989)** + +```go +func (p *Proxy) getGCloudHandlerForRequest(r *http.Request) http.Handler { + if rc := getRunContext(r); rc != nil && rc.GCloudHandler != nil { + return rc.GCloudHandler + } + return p.gcloudHandler +} +``` + +- [ ] **Step 8: Add direct-request handler (mirror `handleDirectAWSCredentials` ~line 1029)** + +The metadata server does not use bearer auth, so we resolve run context by a different mechanism. Two options: + +- **Option A (recommended, matches how containers reach the proxy anyway):** Route `/computeMetadata/` only from proxied requests (r.URL.Host empty + per-run context already resolved via proxy token). Since the container points at `GCE_METADATA_HOST=:` with the normal `HTTP_PROXY` auth token as basic-auth on the `GCE_METADATA_HOST` env var (gcloud supports `http://user:token@host:port` form? — **verify**, if not fall back to option B). + +- **Option B (safer, always works):** Include the run's auth token in the URL path: `GCE_METADATA_HOST=:` and set `GOOGLE_AUTH_TOKEN_URL=http:///computeMetadata/... `? That doesn't work — gcloud hard-codes the metadata path. + Instead: register a dedicated port per run in the daemon for metadata, OR resolve the run by the source container IP (the proxy already knows container IP → run mapping via registry). **Use container IP resolution.** + + Check `internal/daemon/registry.go` / `internal/proxy/proxy.go` for existing container-IP-to-run lookup. If one exists, use it here. If not, add a `contextResolverByIP(ip)` helper. + +Before writing code: **Step 8a** is a 10-minute spike to determine which option works. Read `internal/daemon/registry.go` and search for any container-IP-based resolution. + +Run: `Grep ContainerID|containerIP|resolveByIP internal/daemon/ internal/proxy/` + +If IP-based resolution exists, use it. If not, implement the simpler approach: embed the auth token in a path prefix, e.g. `/_gcloud//computeMetadata/v1/...`, and set `GCE_METADATA_HOST=:/_gcloud/`. **Gotcha:** `GCE_METADATA_HOST` is hostname:port only, it does not accept a path. So this path-prefix approach does NOT work. + +**Resolution:** The proxy is already running in front of the container with `HTTP_PROXY`. When a container makes a GET to `http://metadata.google.internal/computeMetadata/v1/...`, the Google client libraries honor `HTTP_PROXY`. The request reaches our proxy with `Proxy-Authorization: Basic ` already set. So the existing per-run context resolution via proxy token works — we just need to NOT set `GCE_METADATA_HOST` (leave it at `metadata.google.internal`) and instead ensure `metadata.google.internal` is routed to the proxy and responses are handled by `gcloudHandler`. + +BUT: the normal moat TLS-intercepting proxy would try to CONNECT to the real metadata server. We need an early bypass that says "if the request host is metadata.google.internal or 169.254.169.254, serve locally from `gcloudHandler` instead of forwarding." This is cleaner than inventing a URL scheme. + +**Final decision:** In `ServeHTTP`, before the CONNECT handling, add: + +```go +if r.Host == "metadata.google.internal" || strings.HasPrefix(r.Host, "169.254.169.254") { + if h := p.getGCloudHandlerForRequest(r); h != nil { + h.ServeHTTP(w, r) + return + } +} +``` + +Metadata is plain HTTP (no HTTPS), so there is no CONNECT — these are regular proxied GETs the proxy receives in `ServeHTTP`. The per-run `RunContextData` has already been set by the contextResolver above (line 1089–1101) at this point in the flow, so `getRunContext(r)` works. + +Put this block **after** the contextResolver block (~line 1105) but **before** the existing AWS handler check (~line 1107), so run context is populated. + +- [ ] **Step 9: Implement the routing** + +Make the code change described in step 8 final decision. Drop the `/_gcloud/` path ideas. + +Also do NOT set `GCE_METADATA_HOST` in the container. Instead: (a) do nothing, letting the normal metadata hostname resolve into the proxy via `HTTP_PROXY`; (b) ensure the proxy does not reject `metadata.google.internal` in any allow-list. + +- [ ] **Step 10: Update the test** + +Rewrite the test to set `req.Host = "metadata.google.internal"` instead of an empty host and an absolute `/computeMetadata/...` path. Actually the full proxied-GET form: method `GET`, `URL = http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token`, headers `Metadata-Flavor: Google`. + +Also test that without `Metadata-Flavor` the handler returns 403 (delegated to the gcloud endpoint). + +- [ ] **Step 11: Run tests** + +Run: `go test ./internal/proxy/ -run TestGCloudMetadata` +Expected: PASS. + +Run full proxy tests to ensure no regressions: `go test ./internal/proxy/...` +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +git add internal/proxy/ +git commit -m "feat(gcloud): route metadata.google.internal to per-run handler" +``` + +### Task 7: Run manager wiring + +**Files:** +- Modify: `internal/run/run.go` +- Modify: `internal/run/manager.go` + +- [ ] **Step 1: Read `manager.go` lines 640–1000** + +Understand the AWS setup block so the gcloud block can slot alongside it. + +- [ ] **Step 2: Add `GCloudCredentialProvider` field to `run.go`** + +In `internal/run/run.go` near line 99: + +```go +GCloudCredentialProvider *gcloudprov.CredentialProvider +``` + +Add import: + +```go +gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" +``` + +- [ ] **Step 3: In `manager.go`, add gcloud-config detection near the AWS block (~line 660)** + +When a credential with provider `"gcloud"` is encountered, parse config via `gcloudprov.ConfigFromCredential(provCred)`, build `CredentialProvider`, set `r.GCloudCredentialProvider`, and populate `runCtx.GCloudConfig = &daemon.GCloudConfig{...}`. + +- [ ] **Step 4: In `manager.go`, add container env block near the AWS env block (~line 984)** + +```go +if r.GCloudCredentialProvider != nil { + proxyEnv = append(proxyEnv, + "GOOGLE_CLOUD_PROJECT="+r.GCloudCredentialProvider.ProjectID(), + "CLOUDSDK_CORE_PROJECT="+r.GCloudCredentialProvider.ProjectID(), + // metadata.google.internal and 169.254.169.254 are routed by the proxy + // to the gcloud metadata emulation handler. Client libraries find this + // automatically via ADC when HTTP_PROXY is set. + "GOOGLE_APPLICATION_CREDENTIALS=", // clear any inherited value + "CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE=true", + ) +} +``` + +No files are written, no mounts needed — strictly better than AWS. + +- [ ] **Step 5: Update the daemon register request** + +Near the line that already sets `runCtx.AWSConfig`, also populate `runCtx.GCloudConfig` and ensure the `RegisterRequest` in `internal/daemon/api.go` is passed through. Follow exactly how AWSConfig flows today. + +- [ ] **Step 6: Build and run unit tests** + +Run: `go build ./... && make test-unit` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/run/ +git commit -m "feat(gcloud): wire provider into run manager" +``` + +### Task 8: End-to-end sanity test (manual, documented) + +**Files:** none. + +- [ ] **Step 1: Build binary** + +Run: `go build -o /tmp/moat ./cmd/moat` + +- [ ] **Step 2: Verify host has ADC** + +Run: `gcloud auth application-default print-access-token` +Expected: prints a token. + +- [ ] **Step 3: Grant gcloud** + +Run: `/tmp/moat grant gcloud --project=$(gcloud config get-value project)` +Expected: success message. + +- [ ] **Step 4: Launch an interactive run with the grant** + +Run: `/tmp/moat run --grant gcloud -- bash` + +- [ ] **Step 5: Inside the container, verify** + +Run: +```bash +curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/project/project-id +curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token | jq +gcloud auth print-access-token +gcloud projects list --limit=1 +``` +Expected: project id, access token JSON, access token string, project list. + +- [ ] **Step 6: Verify no secrets leaked** + +Run inside container: +```bash +ls ~/.config/gcloud/ 2>/dev/null || echo "no gcloud dir" +env | grep -i google +env | grep -i gcloud +``` +Expected: no `~/.config/gcloud/` unless gcloud itself created one; only `GOOGLE_CLOUD_PROJECT` / `CLOUDSDK_*` in env; no tokens. + +- [ ] **Step 7: Record results** + +Paste the output as a comment in the PR, no commit needed. + +### Task 9: Documentation and changelog + +**Files:** +- Create: `docs/content/guides/XX-gcloud.md` (pick next free number) +- Modify: `docs/content/reference/02-moat-yaml.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Write the guide** + +Follow `docs/STYLE-GUIDE.md`. Keep it short: + +1. Prerequisites: `gcloud auth application-default login` on the host. +2. `moat grant gcloud [--project ] [--key-file ] [--impersonate-service-account ]`. +3. `moat run --grant gcloud -- `. +4. Notes: credentials never enter the container; metadata server is emulated; impersonation and key files stay on the host. +5. Troubleshooting: "no host credentials found" → run ADC login; "invalid_grant" → refresh-token revoked. + +- [ ] **Step 2: Update moat.yaml reference** + +Add `gcloud` to the list of supported grants with its metadata fields. + +- [ ] **Step 3: Update CHANGELOG.md** + +Under the next unreleased version, add: + +```markdown +### Added + +- **gcloud credential provider** — authenticate the Google Cloud CLI and every Google client library inside a moat sandbox without leaking refresh tokens or service account keys. The host daemon mints short-lived access tokens via Application Default Credentials and serves them to the container through an emulated GCE metadata server. Use `moat grant gcloud` then `moat run --grant gcloud`. ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +``` + +- [ ] **Step 4: Run lint** + +Run: `make lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ CHANGELOG.md +git commit -m "docs(gcloud): guide, reference, and changelog entry" +``` + +--- + +## Final verification checklist (before PR) + +Use @superpowers:verification-before-completion. + +- [ ] `go build ./...` clean +- [ ] `make test-unit` PASS (no new skipped tests) +- [ ] `make lint` clean +- [ ] Manual E2E from Task 8 runs successfully and output is recorded +- [ ] No `~/.config/gcloud/` mounted into the container +- [ ] No refresh tokens or SA keys in container env or files +- [ ] Daemon persistence round-trips `GCloudConfig` +- [ ] Daemon API remains backwards-compatible (only additive fields) +- [ ] Guide and changelog updated + +## Known risks and open questions + +1. **Hostname resolution inside the container.** `metadata.google.internal` is a real DNS name that resolves to `169.254.169.254` on GCE. Inside a moat container, DNS may not resolve it. Mitigations: + - (a) Add an `/etc/hosts` entry in the container mapping both names to a routable sentinel (e.g., `127.0.0.1` would not work because the proxy is not local). Better: map to the proxy host. + - (b) Rely on `HTTP_PROXY` — most Google client libraries honor it even for metadata (they use the standard `http.Transport`). Verify: **spike test this in Task 8 before committing to this approach.** + - (c) Fall back to `GCE_METADATA_HOST=:` — this is the documented override and does not require DNS. If (a) and (b) don't work, use this. It requires reopening the Task 6/7 decision about how the proxy identifies the run. + If (c) is required: use container IP to run lookup in the daemon registry. Search confirmed this capability exists if `ContainerID` → run lookup is present. + +2. **`metadata.google.internal` plain HTTP vs TLS.** Real metadata is HTTP only. Our proxy currently intercepts HTTPS via MITM. Plain HTTP goes through transparently. Verify that the proxy's direct HTTP path is reached for this host. + +3. **Proxy health checks that probe `169.254.169.254` with a 250ms timeout.** Google client libraries probe the metadata server at startup. The emulator must respond within that window or libraries silently fall back to "no credentials." Our handler is in-process so latency is sub-millisecond — should be fine, but note it if tests flake. + +4. **`CLOUDSDK_AUTH_ACCESS_TOKEN` alternative.** If metadata emulation turns out to be too fiddly, we can fall back to injecting a raw access token via env var. It works for gcloud CLI only (not client libraries) and needs refresh every hour. Document this as the fallback. + +5. **ID tokens (`audience=…`).** Deferred. Document as a known gap in the guide. Adding later is purely additive to `EndpointHandler`. + +--- + +## Review loop + +After writing this plan, dispatch a single plan-document-reviewer subagent pointing at this file and the research summary above. Address any issues, re-review, and proceed to execution via @superpowers:subagent-driven-development. diff --git a/go.mod b/go.mod index 25087076..b4fd61fd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 github.com/aymanbagabas/go-nativeclipboard v0.1.3 github.com/charmbracelet/x/vt v0.0.0-20260127155452-b72a9a918687 @@ -21,9 +22,11 @@ require ( github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.47.0 + golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.40.0 golang.org/x/term v0.39.0 + google.golang.org/api v0.215.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.43.0 ) @@ -31,6 +34,9 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/auth v0.13.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -47,7 +53,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect @@ -98,8 +103,11 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 75057777..a7162fa7 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,14 @@ cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTj cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= +cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= +cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU= +cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= @@ -49,8 +55,6 @@ github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9 github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= @@ -59,12 +63,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUT github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= @@ -83,8 +83,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLz github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k= @@ -260,12 +258,18 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= @@ -314,8 +318,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/majorcontext/keep v0.2.3 h1:lrJOjHTYwPTh9iZc6w3BngNqlzEAOenbLT5yAwrh9g8= -github.com/majorcontext/keep v0.2.3/go.mod h1:UNSZPfJrZ5i8zw5JYnVk92y4G3DI3VwcnDYvz+J8XXE= github.com/majorcontext/keep v0.3.0 h1:pHSZKFhlyBw0/VC7hA9uJZVrGw2xMa1f2QveS/JEXR8= github.com/majorcontext/keep v0.3.0/go.mod h1:UNSZPfJrZ5i8zw5JYnVk92y4G3DI3VwcnDYvz+J8XXE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -596,6 +598,8 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -692,6 +696,8 @@ google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsb google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0= +google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/daemon/api.go b/internal/daemon/api.go index 20e4a935..4d4e6f7f 100644 --- a/internal/daemon/api.go +++ b/internal/daemon/api.go @@ -74,6 +74,7 @@ type RegisterRequest struct { NetworkRules []netrules.HostRules `json:"network_rules,omitempty"` Grants []string `json:"grants,omitempty"` AWSConfig *AWSConfig `json:"aws_config,omitempty"` + GCloudConfig *GCloudConfig `json:"gcloud_config,omitempty"` ResponseTransformers []TransformerSpec `json:"response_transformers,omitempty"` PolicyYAML map[string][]byte `json:"policy_yaml,omitempty"` PolicyRuleSets []PolicyRuleSetSpec `json:"policy_rule_sets,omitempty"` @@ -144,6 +145,7 @@ func (req *RegisterRequest) ToRunContext() *RunContext { rc.NetworkAllow = req.NetworkAllow rc.NetworkRules = req.NetworkRules rc.AWSConfig = req.AWSConfig + rc.GCloudConfig = req.GCloudConfig rc.Grants = req.Grants rc.TransformerSpecs = req.ResponseTransformers rc.HostGateway = req.HostGateway diff --git a/internal/daemon/persist.go b/internal/daemon/persist.go index 13a1234a..e2e746a2 100644 --- a/internal/daemon/persist.go +++ b/internal/daemon/persist.go @@ -16,6 +16,7 @@ import ( "github.com/majorcontext/moat/internal/log" "github.com/majorcontext/moat/internal/provider" awsprov "github.com/majorcontext/moat/internal/providers/aws" + gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" ) // PersistedRun is the on-disk representation of a registered run. @@ -32,6 +33,7 @@ type PersistedRun struct { NetworkPolicy string `json:"network_policy,omitempty"` NetworkAllow []string `json:"network_allow,omitempty"` AWSConfig *AWSConfig `json:"aws_config,omitempty"` + GCloudConfig *GCloudConfig `json:"gcloud_config,omitempty"` TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` } @@ -77,6 +79,7 @@ func (p *RunPersister) Save() error { NetworkPolicy: rc.NetworkPolicy, NetworkAllow: rc.NetworkAllow, AWSConfig: rc.AWSConfig, + GCloudConfig: rc.GCloudConfig, TransformerSpecs: rc.TransformerSpecs, } rc.mu.RUnlock() @@ -206,6 +209,7 @@ func RestoreRuns(ctx context.Context, registry *Registry, runs []PersistedRun) i rc.NetworkPolicy = pr.NetworkPolicy rc.NetworkAllow = pr.NetworkAllow rc.AWSConfig = pr.AWSConfig + rc.GCloudConfig = pr.GCloudConfig rc.TransformerSpecs = pr.TransformerSpecs if err := resolveCredentials(rc, pr.Grants, pr.MCPServers, store); err != nil { @@ -249,6 +253,24 @@ func RestoreRuns(ctx context.Context, registry *Registry, runs []PersistedRun) i } } + // Set up gcloud credential provider if configured. + if pr.GCloudConfig != nil { + gcloudCfg := &gcloudprov.Config{ + ProjectID: pr.GCloudConfig.ProjectID, + Scopes: pr.GCloudConfig.Scopes, + ImpersonateSA: pr.GCloudConfig.ImpersonateSA, + KeyFile: pr.GCloudConfig.KeyFile, + Email: pr.GCloudConfig.Email, + } + gcloudCP, gcloudErr := gcloudprov.NewCredentialProvider(runCtx, gcloudCfg) + if gcloudErr != nil { + log.Warn("restore: failed to create gcloud credential provider", + "run_id", pr.RunID, "error", gcloudErr) + } else { + rc.SetGCloudHandler(gcloudprov.NewEndpointHandler(gcloudCP)) + } + } + registry.RegisterWithToken(rc, pr.AuthToken) log.Info("restored run from disk", diff --git a/internal/daemon/persist_test.go b/internal/daemon/persist_test.go index 1f0550c6..fc7a7567 100644 --- a/internal/daemon/persist_test.go +++ b/internal/daemon/persist_test.go @@ -29,6 +29,7 @@ func TestRunPersister_SaveAndLoad(t *testing.T) { rc2.ContainerID = "container-abc" rc2.Grants = []string{"claude"} rc2.AWSConfig = &AWSConfig{RoleARN: "arn:aws:iam::123:role/test", Region: "us-east-1"} + rc2.GCloudConfig = &GCloudConfig{ProjectID: "test-project", Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}} rc2.TransformerSpecs = []TransformerSpec{ {Host: "api.github.com", Kind: "response-scrub"}, {Host: "api.anthropic.com", Kind: "oauth-endpoint-workaround"}, @@ -83,6 +84,9 @@ func TestRunPersister_SaveAndLoad(t *testing.T) { if pr2.AWSConfig == nil || pr2.AWSConfig.RoleARN != "arn:aws:iam::123:role/test" { t.Errorf("run-2 AWSConfig = %v, want role ARN arn:aws:iam::123:role/test", pr2.AWSConfig) } + if pr2.GCloudConfig == nil || pr2.GCloudConfig.ProjectID != "test-project" { + t.Errorf("run-2 GCloudConfig = %v, want project test-project", pr2.GCloudConfig) + } if len(pr2.TransformerSpecs) != 2 { t.Errorf("run-2 TransformerSpecs len = %d, want 2", len(pr2.TransformerSpecs)) } else if pr2.TransformerSpecs[0].Kind != "response-scrub" { diff --git a/internal/daemon/registry.go b/internal/daemon/registry.go index d3315911..546ac263 100644 --- a/internal/daemon/registry.go +++ b/internal/daemon/registry.go @@ -3,6 +3,7 @@ package daemon import ( "crypto/rand" "encoding/hex" + "net/http" "sync" ) @@ -117,6 +118,37 @@ func (r *Registry) Count() int { return len(r.runs) } +// FindGCloudHandler returns the gcloud metadata handler for direct metadata +// requests (via GCE_METADATA_HOST) where no proxy auth token is available +// to identify the run. +// +// When multiple runs have gcloud configured, the handler is returned only if +// all runs share the same credential profile — same profile means same +// underlying credentials, so any handler is equivalent. Returns nil if runs +// use different profiles to prevent cross-run credential leakage. +func (r *Registry) FindGCloudHandler() http.Handler { + r.mu.RLock() + defer r.mu.RUnlock() + var found http.Handler + var foundProfile string + first := true + for _, rc := range r.runs { + if h := rc.GCloudHandler(); h != nil { + profile := rc.GCloudProfile() + if first { + found = h + foundProfile = profile + first = false + } else if profile != foundProfile { + // Different profiles — refuse to serve to prevent + // cross-run credential leakage. + return nil + } + } + } + return found +} + // generateToken returns a 32-byte cryptographically random hex string. func generateToken() string { b := make([]byte, 32) diff --git a/internal/daemon/registry_test.go b/internal/daemon/registry_test.go index 3e516cb1..898a50a2 100644 --- a/internal/daemon/registry_test.go +++ b/internal/daemon/registry_test.go @@ -1,6 +1,7 @@ package daemon import ( + "net/http" "testing" ) @@ -123,3 +124,104 @@ func TestRegistry_UniqueTokens(t *testing.T) { t.Errorf("token2 length = %d, want 64", len(token2)) } } + +func dummyHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func TestRegistry_FindGCloudHandler_SingleRun(t *testing.T) { + reg := NewRegistry() + rc := NewRunContext("run-1") + rc.GCloudConfig = &GCloudConfig{Profile: "dev"} + rc.SetGCloudHandler(dummyHandler()) + reg.Register(rc) + + if h := reg.FindGCloudHandler(); h == nil { + t.Error("FindGCloudHandler returned nil for single gcloud run") + } +} + +func TestRegistry_FindGCloudHandler_SameProfile(t *testing.T) { + reg := NewRegistry() + + rc1 := NewRunContext("run-1") + rc1.GCloudConfig = &GCloudConfig{Profile: "dev"} + rc1.SetGCloudHandler(dummyHandler()) + reg.Register(rc1) + + rc2 := NewRunContext("run-2") + rc2.GCloudConfig = &GCloudConfig{Profile: "dev"} + rc2.SetGCloudHandler(dummyHandler()) + reg.Register(rc2) + + if h := reg.FindGCloudHandler(); h == nil { + t.Error("FindGCloudHandler returned nil for multiple runs with same profile") + } +} + +func TestRegistry_FindGCloudHandler_DifferentProfiles(t *testing.T) { + reg := NewRegistry() + + rc1 := NewRunContext("run-1") + rc1.GCloudConfig = &GCloudConfig{Profile: "dev"} + rc1.SetGCloudHandler(dummyHandler()) + reg.Register(rc1) + + rc2 := NewRunContext("run-2") + rc2.GCloudConfig = &GCloudConfig{Profile: "prod"} + rc2.SetGCloudHandler(dummyHandler()) + reg.Register(rc2) + + if h := reg.FindGCloudHandler(); h != nil { + t.Error("FindGCloudHandler should return nil for runs with different profiles") + } +} + +func TestRegistry_FindGCloudHandler_DefaultProfile(t *testing.T) { + reg := NewRegistry() + + // Two runs with default (empty) profile — should succeed. + rc1 := NewRunContext("run-1") + rc1.GCloudConfig = &GCloudConfig{} + rc1.SetGCloudHandler(dummyHandler()) + reg.Register(rc1) + + rc2 := NewRunContext("run-2") + rc2.GCloudConfig = &GCloudConfig{} + rc2.SetGCloudHandler(dummyHandler()) + reg.Register(rc2) + + if h := reg.FindGCloudHandler(); h == nil { + t.Error("FindGCloudHandler returned nil for multiple runs with default profile") + } +} + +func TestRegistry_FindGCloudHandler_MixedGCloudAndNon(t *testing.T) { + reg := NewRegistry() + + // One run with gcloud, one without — should return the gcloud handler. + rc1 := NewRunContext("run-1") + rc1.GCloudConfig = &GCloudConfig{Profile: "dev"} + rc1.SetGCloudHandler(dummyHandler()) + reg.Register(rc1) + + rc2 := NewRunContext("run-2") + // No gcloud config + reg.Register(rc2) + + if h := reg.FindGCloudHandler(); h == nil { + t.Error("FindGCloudHandler returned nil when only one run has gcloud") + } +} + +func TestRegistry_FindGCloudHandler_NoGCloud(t *testing.T) { + reg := NewRegistry() + rc := NewRunContext("run-1") + reg.Register(rc) + + if h := reg.FindGCloudHandler(); h != nil { + t.Error("FindGCloudHandler should return nil when no runs have gcloud") + } +} diff --git a/internal/daemon/runcontext.go b/internal/daemon/runcontext.go index 61a9c91f..31b3b277 100644 --- a/internal/daemon/runcontext.go +++ b/internal/daemon/runcontext.go @@ -45,6 +45,18 @@ type AWSConfig struct { Profile string `json:"profile,omitempty"` } +// GCloudConfig holds Google Cloud credential provider configuration. +// All long-lived credential material stays on the host; the container +// only sees short-lived access tokens served via metadata emulation. +type GCloudConfig struct { + ProjectID string `json:"project_id"` + Scopes []string `json:"scopes,omitempty"` + ImpersonateSA string `json:"impersonate_service_account,omitempty"` + KeyFile string `json:"key_file,omitempty"` + Email string `json:"email,omitempty"` + Profile string `json:"profile,omitempty"` +} + // RunContext holds per-run proxy state. It implements credential.ProxyConfigurer // so providers can configure it identically to how they configure proxy.Proxy. type RunContext struct { @@ -64,6 +76,7 @@ type RunContext struct { NetworkRules []netrules.HostRules `json:"network_rules,omitempty"` AWSConfig *AWSConfig `json:"aws_config,omitempty"` + GCloudConfig *GCloudConfig `json:"gcloud_config,omitempty"` TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` Grants []string `json:"grants,omitempty"` HostGateway string `json:"host_gateway,omitempty"` @@ -74,6 +87,7 @@ type RunContext struct { KeepEngines map[string]*keeplib.Engine `json:"-"` // compiled Keep policy engines per scope refreshCancel context.CancelFunc `json:"-"` // cancels token refresh goroutine awsHandler http.Handler `json:"-"` // AWS credential endpoint handler + gcloudHandler http.Handler `json:"-"` // gcloud metadata endpoint handler mu sync.RWMutex } @@ -150,6 +164,31 @@ func (rc *RunContext) SetAWSHandler(h http.Handler) { rc.awsHandler = h } +// SetGCloudHandler stores the gcloud metadata endpoint handler for this run. +func (rc *RunContext) SetGCloudHandler(h http.Handler) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.gcloudHandler = h +} + +// GCloudHandler returns the gcloud metadata endpoint handler, if configured. +func (rc *RunContext) GCloudHandler() http.Handler { + rc.mu.RLock() + defer rc.mu.RUnlock() + return rc.gcloudHandler +} + +// GCloudProfile returns the credential profile for this run's gcloud config. +// Returns "" for the default (unscoped) profile. +func (rc *RunContext) GCloudProfile() string { + rc.mu.RLock() + defer rc.mu.RUnlock() + if rc.GCloudConfig != nil { + return rc.GCloudConfig.Profile + } + return "" +} + // SetCredential implements credential.ProxyConfigurer. func (rc *RunContext) SetCredential(host, value string) { rc.SetCredentialHeader(host, "Authorization", value) @@ -414,6 +453,9 @@ func (rc *RunContext) ToProxyContextData() *proxy.RunContextData { // Include AWS handler if configured. d.AWSHandler = rc.awsHandler + // Include gcloud handler if configured. + d.GCloudHandler = rc.gcloudHandler + // Propagate Keep policy engines. d.KeepEngines = rc.KeepEngines diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 88c3398f..9c5e03e5 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -14,6 +14,7 @@ import ( "github.com/majorcontext/moat/internal/log" awsprov "github.com/majorcontext/moat/internal/providers/aws" + gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" "github.com/majorcontext/moat/internal/routing" ) @@ -250,6 +251,24 @@ func (s *Server) handleRegisterRun(w http.ResponseWriter, r *http.Request) { } } + // Create gcloud credential provider if configured. + if req.GCloudConfig != nil { + gcloudCfg := &gcloudprov.Config{ + ProjectID: req.GCloudConfig.ProjectID, + Scopes: req.GCloudConfig.Scopes, + ImpersonateSA: req.GCloudConfig.ImpersonateSA, + KeyFile: req.GCloudConfig.KeyFile, + Email: req.GCloudConfig.Email, + } + gcloudCP, gcloudErr := gcloudprov.NewCredentialProvider(runCtx, gcloudCfg) + if gcloudErr != nil { + log.Warn("failed to create gcloud credential provider for run", + "run_id", rc.RunID, "error", gcloudErr) + } else { + rc.SetGCloudHandler(gcloudprov.NewEndpointHandler(gcloudCP)) + } + } + // Register the fully-initialized RunContext so the proxy never sees // an incomplete run. s.registry.RegisterWithToken(rc, token) diff --git a/internal/providers/gcloud/credential_provider.go b/internal/providers/gcloud/credential_provider.go new file mode 100644 index 00000000..bfea0e42 --- /dev/null +++ b/internal/providers/gcloud/credential_provider.go @@ -0,0 +1,127 @@ +package gcloud + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/impersonate" +) + +// credentialRefreshBuffer is the time before expiration when tokens should be refreshed. +const credentialRefreshBuffer = 5 * time.Minute + +// CredentialProvider manages Google Cloud token fetching and caching. +// It wraps an oauth2.TokenSource and adds caching with a refresh buffer. +type CredentialProvider struct { + cfg *Config + source oauth2.TokenSource + mu sync.Mutex + cached *oauth2.Token +} + +// NewCredentialProvider creates a new Google Cloud credential provider +// using Application Default Credentials, a key file, or impersonation. +func NewCredentialProvider(ctx context.Context, cfg *Config) (*CredentialProvider, error) { + source, err := buildTokenSource(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("building token source: %w", err) + } + return &CredentialProvider{ + cfg: cfg, + source: source, + }, nil +} + +// NewCredentialProviderFromTokenSource creates a CredentialProvider from a +// custom token source. This is intended for testing. +func NewCredentialProviderFromTokenSource(ts oauth2.TokenSource, cfg *Config) *CredentialProvider { + return &CredentialProvider{ + cfg: cfg, + source: ts, + } +} + +// buildTokenSource constructs the appropriate oauth2.TokenSource for the given config. +func buildTokenSource(ctx context.Context, cfg *Config) (oauth2.TokenSource, error) { + scopes := cfg.Scopes + if len(scopes) == 0 { + scopes = []string{DefaultScope} + } + + // Key file takes precedence. If ImpersonateSA is also set, it is ignored — + // impersonation using a key file as the caller is not currently supported. + if cfg.KeyFile != "" { + data, err := os.ReadFile(cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("reading key file %s: %w", cfg.KeyFile, err) + } + creds, err := google.CredentialsFromJSON(ctx, data, scopes...) + if err != nil { + return nil, fmt.Errorf("parsing key file: %w", err) + } + return creds.TokenSource, nil + } + + // Impersonation wraps the default credentials. + if cfg.ImpersonateSA != "" { + ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: cfg.ImpersonateSA, + Scopes: scopes, + }) + if err != nil { + return nil, fmt.Errorf("impersonating %s: %w", cfg.ImpersonateSA, err) + } + return ts, nil + } + + // Default: Application Default Credentials. + creds, err := google.FindDefaultCredentials(ctx, scopes...) + if err != nil { + return nil, fmt.Errorf("finding default credentials: %w", err) + } + return creds.TokenSource, nil +} + +// GetToken returns a valid access token, refreshing if necessary. +func (p *CredentialProvider) GetToken(ctx context.Context) (*oauth2.Token, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + p.mu.Lock() + defer p.mu.Unlock() + + // Return cached if still valid with buffer. + if p.cached != nil && p.cached.Valid() && + (p.cached.Expiry.IsZero() || time.Now().Add(credentialRefreshBuffer).Before(p.cached.Expiry)) { + return p.cached, nil + } + + tok, err := p.source.Token() + if err != nil { + return nil, fmt.Errorf("fetching token: %w", err) + } + + p.cached = tok + return tok, nil +} + +// ProjectID returns the configured project ID. +func (p *CredentialProvider) ProjectID() string { + return p.cfg.ProjectID +} + +// Scopes returns a copy of the configured OAuth scopes. +func (p *CredentialProvider) Scopes() []string { + return append([]string(nil), p.cfg.Scopes...) +} + +// Email returns the configured email identity. +func (p *CredentialProvider) Email() string { + return p.cfg.Email +} diff --git a/internal/providers/gcloud/credential_provider_test.go b/internal/providers/gcloud/credential_provider_test.go new file mode 100644 index 00000000..f9336705 --- /dev/null +++ b/internal/providers/gcloud/credential_provider_test.go @@ -0,0 +1,86 @@ +package gcloud + +import ( + "context" + "testing" + "time" + + "golang.org/x/oauth2" +) + +type fakeTokenSource struct { + tok *oauth2.Token + err error + hits int +} + +func (f *fakeTokenSource) Token() (*oauth2.Token, error) { + f.hits++ + return f.tok, f.err +} + +func TestCredentialProviderReturnsToken(t *testing.T) { + exp := time.Now().Add(1 * time.Hour) + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "ya29.fake", Expiry: exp}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + tok, err := p.GetToken(context.Background()) + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.AccessToken != "ya29.fake" { + t.Errorf("AccessToken = %q", tok.AccessToken) + } +} + +func TestCredentialProviderCaches(t *testing.T) { + exp := time.Now().Add(1 * time.Hour) + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: exp}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + for i := 0; i < 5; i++ { + _, _ = p.GetToken(context.Background()) + } + if fts.hits > 1 { + t.Errorf("expected caching, token source hit %d times", fts.hits) + } +} + +func TestCredentialProviderRefreshesOnExpiry(t *testing.T) { + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: time.Now().Add(1 * time.Minute)}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + _, _ = p.GetToken(context.Background()) + _, _ = p.GetToken(context.Background()) + if fts.hits < 2 { + t.Errorf("expected refresh within buffer window, hits=%d", fts.hits) + } +} + +func TestCredentialProviderContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: time.Now().Add(1 * time.Hour)}} + p := NewCredentialProviderFromTokenSource(fts, &Config{ProjectID: "p"}) + _, err := p.GetToken(ctx) + if err == nil { + t.Error("expected error for canceled context") + } +} + +func TestCredentialProviderGetters(t *testing.T) { + cfg := &Config{ + ProjectID: "test-project", + Scopes: []string{"scope1", "scope2"}, + Email: "test@example.com", + } + fts := &fakeTokenSource{tok: &oauth2.Token{AccessToken: "a", Expiry: time.Now().Add(1 * time.Hour)}} + p := NewCredentialProviderFromTokenSource(fts, cfg) + + if p.ProjectID() != "test-project" { + t.Errorf("ProjectID() = %q", p.ProjectID()) + } + if len(p.Scopes()) != 2 { + t.Errorf("Scopes() = %v", p.Scopes()) + } + if p.Email() != "test@example.com" { + t.Errorf("Email() = %q", p.Email()) + } +} diff --git a/internal/providers/gcloud/doc.go b/internal/providers/gcloud/doc.go new file mode 100644 index 00000000..5344dcc2 --- /dev/null +++ b/internal/providers/gcloud/doc.go @@ -0,0 +1,10 @@ +// Package gcloud implements a credential provider for Google Cloud. +// +// Unlike header-injection providers (GitHub, Claude), gcloud follows the +// AWS model: the host daemon mints short-lived access tokens using the +// host's Application Default Credentials and serves them to the container +// via a GCE metadata server emulator. The container's HTTP_PROXY routes +// requests to metadata.google.internal through the proxy, which serves +// them from the per-run metadata handler. No long-lived credentials enter +// the container. +package gcloud diff --git a/internal/providers/gcloud/endpoint.go b/internal/providers/gcloud/endpoint.go new file mode 100644 index 00000000..08c86833 --- /dev/null +++ b/internal/providers/gcloud/endpoint.go @@ -0,0 +1,232 @@ +package gcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/majorcontext/moat/internal/log" +) + +// DefaultEmail is the fallback email used when the authenticated identity is unknown. +const DefaultEmail = "default@moat.local" + +// EndpointHandler serves GCE metadata server emulation routes. +// It implements http.Handler and responds to the subset of metadata +// endpoints that gcloud CLI and Google client libraries require. +type EndpointHandler struct { + getToken func(ctx context.Context) (*oauth2.Token, error) + projectID string + scopes []string + email string +} + +// NewEndpointHandler creates a metadata emulation handler backed by a +// CredentialProvider. +func NewEndpointHandler(cp *CredentialProvider) *EndpointHandler { + email := cp.Email() + if email == "" { + email = DefaultEmail + } + return &EndpointHandler{ + getToken: cp.GetToken, + projectID: cp.ProjectID(), + scopes: cp.Scopes(), + email: email, + } +} + +// NewEndpointHandlerFromTokenFunc creates a handler with a custom token +// function. This is intended for testing. +func NewEndpointHandlerFromTokenFunc( + getToken func(ctx context.Context) (*oauth2.Token, error), + projectID string, + scopes []string, + email string, +) *EndpointHandler { + if email == "" { + email = DefaultEmail + } + return &EndpointHandler{ + getToken: getToken, + projectID: projectID, + scopes: scopes, + email: email, + } +} + +// ServeHTTP implements http.Handler. All requests must include the +// Metadata-Flavor: Google header or receive a 403 response. +func (h *EndpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Require Metadata-Flavor header on all requests. + if r.Header.Get("Metadata-Flavor") != "Google" { + http.Error(w, "Missing required header: Metadata-Flavor: Google", http.StatusForbidden) + return + } + + // All responses include the Metadata-Flavor header. + w.Header().Set("Metadata-Flavor", "Google") + + // Normalize path: replace email-based service account identifiers with + // "default". The gcloud CLI uses the account email in the path (e.g., + // /service-accounts/user@project.iam.gserviceaccount.com/) instead of + // /service-accounts/default/. Normalize to simplify routing. + path := h.normalizeSAPath(r.URL.Path) + + // Handle ?recursive=true on service account paths. The gcloud CLI + // requests this to get all account details in a single JSON response. + if r.URL.Query().Get("recursive") == "true" && strings.HasPrefix(path, saPrefix) { + h.serveRecursive(w) + return + } + + switch path { + // Liveness probes. + case "/", "/computeMetadata/", "/computeMetadata/v1/": + w.WriteHeader(http.StatusOK) + + // Token endpoint. + case "/computeMetadata/v1/instance/service-accounts/default/token": + h.serveToken(w, r) + + // Email endpoint. + case "/computeMetadata/v1/instance/service-accounts/default/email": + fmt.Fprint(w, h.email) + + // Scopes endpoint. + case "/computeMetadata/v1/instance/service-accounts/default/scopes": + fmt.Fprint(w, strings.Join(h.scopes, "\n")) + + // Aliases endpoint. + case "/computeMetadata/v1/instance/service-accounts/default/aliases": + fmt.Fprint(w, "default") + + // Service account directory listing. + case "/computeMetadata/v1/instance/service-accounts/default/", + "/computeMetadata/v1/instance/service-accounts/default": + fmt.Fprint(w, "aliases\nemail\nidentity\nscopes\ntoken\n") + + // Service accounts listing. + case "/computeMetadata/v1/instance/service-accounts/", + "/computeMetadata/v1/instance/service-accounts": + if h.email != DefaultEmail { + fmt.Fprintf(w, "default/\n%s/\n", h.email) + } else { + fmt.Fprint(w, "default/\n") + } + + // Project ID. + case "/computeMetadata/v1/project/project-id": + fmt.Fprint(w, h.projectID) + + // Numeric project ID (not available; return 0). + case "/computeMetadata/v1/project/numeric-project-id": + fmt.Fprint(w, "0") + + // Identity token (not implemented). + case "/computeMetadata/v1/instance/service-accounts/default/identity": + http.Error(w, "identity tokens not implemented", http.StatusNotFound) + + default: + http.NotFound(w, r) + } +} + +const saPrefix = "/computeMetadata/v1/instance/service-accounts/" + +// normalizeSAPath replaces email-based service account identifiers with +// "default" so routing can use a simple switch. For example: +// +// /computeMetadata/v1/instance/service-accounts/user@proj.iam.gserviceaccount.com/token +// +// becomes: +// +// /computeMetadata/v1/instance/service-accounts/default/token +func (h *EndpointHandler) normalizeSAPath(path string) string { + if !strings.HasPrefix(path, saPrefix) { + return path + } + rest := path[len(saPrefix):] + // Empty or "default" — already normalized. + if rest == "" || rest == "default" || strings.HasPrefix(rest, "default/") { + return path + } + // Replace the email (or any identifier) with "default". + if idx := strings.IndexByte(rest, '/'); idx >= 0 { + return saPrefix + "default" + rest[idx:] + } + return saPrefix + "default" +} + +// serveRecursive returns service account details as JSON, used by +// gcloud's ?recursive=true query. +func (h *EndpointHandler) serveRecursive(w http.ResponseWriter) { + resp := map[string]any{ + "aliases": []string{"default"}, + "email": h.email, + "scopes": h.scopes, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error("failed to encode recursive response", "error", err) + } +} + +// serveToken returns an access token in GCE metadata format. +func (h *EndpointHandler) serveToken(w http.ResponseWriter, r *http.Request) { + tok, err := h.getToken(r.Context()) + if err != nil { + msg := classifyError(err) + log.Error("gcloud token fetch error", "error", err) + http.Error(w, msg, http.StatusInternalServerError) + return + } + + expiresIn := 3600 // default for tokens without expiry + if !tok.Expiry.IsZero() { + expiresIn = int(time.Until(tok.Expiry).Seconds()) + if expiresIn < 0 { + expiresIn = 0 + } + } + + resp := map[string]any{ + "access_token": tok.AccessToken, + "expires_in": expiresIn, + "token_type": "Bearer", + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error("failed to encode token response", "error", err) + } +} + +// classifyError returns a user-friendly error message for common token errors. +// Note: matches against error message strings from golang.org/x/oauth2 and google SDK. +// These are not part of a stable API — audit when bumping those dependencies. +func classifyError(err error) string { + msg := err.Error() + + switch { + case strings.Contains(msg, "could not find default credentials"): + return "gcloud credential error: no Application Default Credentials found on host.\n\n" + + "Run 'gcloud auth application-default login' on your host." + + case strings.Contains(msg, "oauth2: cannot fetch token"): + return "gcloud credential error: failed to refresh token.\n\n" + + "Your host credentials may have expired. Run 'gcloud auth application-default login'." + + case strings.Contains(msg, "context canceled") || strings.Contains(msg, "context deadline exceeded"): + return "gcloud credential error: request canceled or timed out." + + default: + return "gcloud credential error: unexpected error fetching token.\n\n" + + "Check the daemon log for details: ~/.moat/debug/daemon.log" + } +} diff --git a/internal/providers/gcloud/endpoint_test.go b/internal/providers/gcloud/endpoint_test.go new file mode 100644 index 00000000..fc6cf298 --- /dev/null +++ b/internal/providers/gcloud/endpoint_test.go @@ -0,0 +1,319 @@ +package gcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func newTestHandler(tok *oauth2.Token, err error) *EndpointHandler { + return NewEndpointHandlerFromTokenFunc( + func(ctx context.Context) (*oauth2.Token, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return tok, err + }, + "test-project", + []string{"https://www.googleapis.com/auth/cloud-platform"}, + "sa@test.iam.gserviceaccount.com", + ) +} + +func metadataRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Metadata-Flavor", "Google") + return req +} + +func TestMetadataRequiresFlavorHeader(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + req := httptest.NewRequest("GET", "/computeMetadata/v1/project/project-id", nil) + // No Metadata-Flavor header. + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +func TestMetadataToken(t *testing.T) { + exp := time.Now().Add(1 * time.Hour) + h := newTestHandler(&oauth2.Token{AccessToken: "ya29.test-token", Expiry: exp}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/token")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp["access_token"] != "ya29.test-token" { + t.Errorf("access_token = %v", resp["access_token"]) + } + if resp["token_type"] != "Bearer" { + t.Errorf("token_type = %v", resp["token_type"]) + } + expiresIn, ok := resp["expires_in"].(float64) + if !ok || expiresIn <= 0 { + t.Errorf("expires_in = %v, want positive number", resp["expires_in"]) + } + + if w.Header().Get("Metadata-Flavor") != "Google" { + t.Error("response missing Metadata-Flavor header") + } +} + +func TestMetadataProjectID(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/project/project-id")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if body := w.Body.String(); body != "test-project" { + t.Errorf("body = %q, want %q", body, "test-project") + } +} + +func TestMetadataNumericProjectID(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/project/numeric-project-id")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if body := w.Body.String(); body != "0" { + t.Errorf("body = %q, want %q", body, "0") + } +} + +func TestMetadataScopes(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/scopes")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if body := w.Body.String(); body != "https://www.googleapis.com/auth/cloud-platform" { + t.Errorf("body = %q", body) + } +} + +func TestMetadataEmail(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/email")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if body := w.Body.String(); body != "sa@test.iam.gserviceaccount.com" { + t.Errorf("body = %q", body) + } +} + +func TestMetadataServiceAccountsDirListing(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + + t.Run("default account listing", func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + for _, expected := range []string{"aliases", "email", "identity", "scopes", "token"} { + if !strings.Contains(body, expected) { + t.Errorf("body missing %q: %q", expected, body) + } + } + }) + + t.Run("accounts listing", func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "default/") { + t.Errorf("body missing default/: %q", body) + } + if !strings.Contains(body, "sa@test.iam.gserviceaccount.com/") { + t.Errorf("body missing email/: %q", body) + } + }) +} + +func TestMetadataLiveness(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + + for _, path := range []string{"/", "/computeMetadata/", "/computeMetadata/v1/"} { + t.Run(path, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", path)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 for %s", w.Code, path) + } + if w.Header().Get("Metadata-Flavor") != "Google" { + t.Error("response missing Metadata-Flavor header") + } + }) + } +} + +func TestMetadataIdentityNotImplemented(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://example.com")) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestMetadataTokenContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h := newTestHandler(nil, fmt.Errorf("context canceled")) + req := httptest.NewRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/token", nil) + req = req.WithContext(ctx) + req.Header.Set("Metadata-Flavor", "Google") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } +} + +func TestMetadataAliases(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/aliases")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if body := w.Body.String(); body != "default" { + t.Errorf("body = %q, want %q", body, "default") + } +} + +func TestMetadataUnknownPath(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/unknown/path")) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestNormalizeSAPath(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + + tests := []struct { + name string + path string + want string + }{ + // Already normalized — should pass through unchanged. + {"default with subpath", saPrefix + "default/token", saPrefix + "default/token"}, + {"default bare", saPrefix + "default", saPrefix + "default"}, + {"default trailing slash", saPrefix + "default/", saPrefix + "default/"}, + {"empty after prefix", saPrefix, saPrefix}, + + // Email-based identifiers — should normalize to "default". + {"email with token", saPrefix + "user@proj.iam.gserviceaccount.com/token", saPrefix + "default/token"}, + {"email with scopes", saPrefix + "user@proj.iam.gserviceaccount.com/scopes", saPrefix + "default/scopes"}, + {"email bare", saPrefix + "user@proj.iam.gserviceaccount.com", saPrefix + "default"}, + {"email with trailing slash", saPrefix + "user@proj.iam.gserviceaccount.com/", saPrefix + "default/"}, + + // Non-SA paths — should pass through unchanged. + {"project path", "/computeMetadata/v1/project/project-id", "/computeMetadata/v1/project/project-id"}, + {"root", "/", "/"}, + {"liveness", "/computeMetadata/v1/", "/computeMetadata/v1/"}, + + // Recursive query path (path portion only, query handled separately). + {"email recursive path", saPrefix + "user@proj.iam.gserviceaccount.com/?recursive=true", saPrefix + "default/?recursive=true"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := h.normalizeSAPath(tt.path) + if got != tt.want { + t.Errorf("normalizeSAPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestNormalizeSAPathRecursiveIntegration(t *testing.T) { + h := newTestHandler(&oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil) + + // Request with email-based SA path and ?recursive=true — should return JSON. + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", saPrefix+"user@proj.iam.gserviceaccount.com/?recursive=true")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp["email"] != "sa@test.iam.gserviceaccount.com" { + t.Errorf("email = %v, want sa@test.iam.gserviceaccount.com", resp["email"]) + } +} + +func TestMetadataServiceAccountsListingDefaultEmail(t *testing.T) { + h := NewEndpointHandlerFromTokenFunc( + func(ctx context.Context) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil + }, + "proj", + []string{DefaultScope}, + "", // empty email → DefaultEmail + ) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/")) + body := w.Body.String() + if !strings.Contains(body, "default/") { + t.Errorf("body missing default/: %q", body) + } + if strings.Contains(body, DefaultEmail) { + t.Errorf("body should not list synthetic email %q: %q", DefaultEmail, body) + } +} + +func TestMetadataDefaultEmail(t *testing.T) { + h := NewEndpointHandlerFromTokenFunc( + func(ctx context.Context) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil + }, + "proj", + []string{DefaultScope}, + "", // empty email should default + ) + w := httptest.NewRecorder() + h.ServeHTTP(w, metadataRequest("GET", "/computeMetadata/v1/instance/service-accounts/default/email")) + if body := w.Body.String(); body != "default@moat.local" { + t.Errorf("body = %q, want default@moat.local", body) + } +} diff --git a/internal/providers/gcloud/grant.go b/internal/providers/gcloud/grant.go new file mode 100644 index 00000000..4d0f0fa3 --- /dev/null +++ b/internal/providers/gcloud/grant.go @@ -0,0 +1,219 @@ +package gcloud + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/majorcontext/moat/internal/provider" + "github.com/majorcontext/moat/internal/ui" +) + +// Metadata keys for gcloud credentials. +const ( + MetaKeyProject = "project" + MetaKeyScopes = "scopes" + MetaKeyImpersonate = "impersonate" + MetaKeyKeyFile = "key_file" + MetaKeyEmail = "email" +) + +// DefaultScope is the default OAuth scope requested for Google Cloud credentials. +const DefaultScope = "https://www.googleapis.com/auth/cloud-platform" + +// Context keys for passing grant options from CLI. +type ctxKey string + +const ( + ctxKeyProject ctxKey = "gcloud_project" + ctxKeyScopes ctxKey = "gcloud_scopes" + ctxKeyImpersonate ctxKey = "gcloud_impersonate" + ctxKeyKeyFile ctxKey = "gcloud_key_file" +) + +// WithGrantOptions returns a context with gcloud grant options set. +// These options are used by Grant() instead of prompting interactively. +func WithGrantOptions(ctx context.Context, project, scopes, impersonate, keyFile string) context.Context { + ctx = context.WithValue(ctx, ctxKeyProject, project) + ctx = context.WithValue(ctx, ctxKeyScopes, scopes) + ctx = context.WithValue(ctx, ctxKeyImpersonate, impersonate) + ctx = context.WithValue(ctx, ctxKeyKeyFile, keyFile) + return ctx +} + +// Config holds Google Cloud credential configuration. +type Config struct { + ProjectID string + Scopes []string + ImpersonateSA string // service account email to impersonate + KeyFile string // path to service account key file + Email string // email of the authenticated identity +} + +// getenv is a variable for testing. Default implementation uses os.Getenv. +var getenv = os.Getenv + +// grant acquires Google Cloud credentials from the host environment. +func grant(ctx context.Context) (*provider.Credential, error) { + cfg := &Config{ + Scopes: []string{DefaultScope}, + } + + // Read options from context (set by CLI flags). + if v, ok := ctx.Value(ctxKeyProject).(string); ok && v != "" { + cfg.ProjectID = v + } + if v, ok := ctx.Value(ctxKeyScopes).(string); ok && v != "" { + cfg.Scopes = splitScopes(v) + } + if v, ok := ctx.Value(ctxKeyImpersonate).(string); ok && v != "" { + cfg.ImpersonateSA = v + } + if v, ok := ctx.Value(ctxKeyKeyFile).(string); ok && v != "" { + if abs, err := filepath.Abs(v); err == nil { + cfg.KeyFile = abs + } else { + cfg.KeyFile = v + } + } + + // Detect project if not provided. + if cfg.ProjectID == "" { + project, err := detectProject(ctx) + if err != nil { + return nil, &provider.GrantError{ + Provider: "gcloud", + Cause: err, + Hint: "Set a project with one of:\n" + + " moat grant gcloud --project MY_PROJECT\n" + + " export GOOGLE_CLOUD_PROJECT=MY_PROJECT\n" + + " gcloud config set project MY_PROJECT", + } + } + cfg.ProjectID = project + } + + // Verify credentials work by building a token source. + if err := testCredentials(ctx, cfg); err != nil { + return nil, &provider.GrantError{ + Provider: "gcloud", + Cause: err, + Hint: "Ensure Google Cloud credentials are configured on your host.\n" + + "Run 'gcloud auth application-default login' or set GOOGLE_APPLICATION_CREDENTIALS.", + } + } + + ui.Infof("Using Google Cloud project: %s", cfg.ProjectID) + + cred := &provider.Credential{ + Provider: "gcloud", + Token: "", // no static token; tokens are minted on demand + CreatedAt: time.Now(), + Metadata: map[string]string{ + MetaKeyProject: cfg.ProjectID, + MetaKeyScopes: strings.Join(cfg.Scopes, ","), + }, + } + + if cfg.ImpersonateSA != "" { + cred.Metadata[MetaKeyImpersonate] = cfg.ImpersonateSA + // The SA email is known from the impersonation target. + if cfg.Email == "" { + cfg.Email = cfg.ImpersonateSA + } + } + if cfg.KeyFile != "" { + cred.Metadata[MetaKeyKeyFile] = cfg.KeyFile + } + if cfg.Email != "" { + cred.Metadata[MetaKeyEmail] = cfg.Email + } + + return cred, nil +} + +// splitScopes splits a comma-separated scope string into a slice. +func splitScopes(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// detectProject attempts to detect the Google Cloud project from the environment. +// It checks GOOGLE_CLOUD_PROJECT, CLOUDSDK_CORE_PROJECT, then gcloud CLI. +func detectProject(ctx context.Context) (string, error) { + // Check environment variables first. + for _, env := range []string{"GOOGLE_CLOUD_PROJECT", "CLOUDSDK_CORE_PROJECT"} { + if v := getenv(env); v != "" { + return v, nil + } + } + + // Try gcloud CLI with context for cancellation/timeout. + out, err := exec.CommandContext(ctx, "gcloud", "config", "get-value", "project").Output() + if err == nil { + project := strings.TrimSpace(string(out)) + if project != "" && project != "(unset)" { + return project, nil + } + } + + return "", fmt.Errorf("could not detect Google Cloud project") +} + +// ConfigFromCredential reconstructs Config from a stored credential's metadata. +func ConfigFromCredential(cred *provider.Credential) (*Config, error) { + if cred == nil { + return nil, fmt.Errorf("credential is nil") + } + + project := cred.Metadata[MetaKeyProject] + if project == "" { + return nil, fmt.Errorf("gcloud credential missing project ID") + } + + cfg := &Config{ + ProjectID: project, + ImpersonateSA: cred.Metadata[MetaKeyImpersonate], + KeyFile: cred.Metadata[MetaKeyKeyFile], + Email: cred.Metadata[MetaKeyEmail], + } + + if scopeStr := cred.Metadata[MetaKeyScopes]; scopeStr != "" { + cfg.Scopes = splitScopes(scopeStr) + } + if len(cfg.Scopes) == 0 { + cfg.Scopes = []string{DefaultScope} + } + + return cfg, nil +} + +// testCredentials verifies that credentials can be obtained for the given config. +func testCredentials(ctx context.Context, cfg *Config) error { + cp, err := NewCredentialProvider(ctx, cfg) + if err != nil { + return err + } + tok, err := cp.GetToken(ctx) + if err != nil { + return err + } + if tok.AccessToken == "" { + return fmt.Errorf("received empty access token") + } + return nil +} diff --git a/internal/providers/gcloud/grant_test.go b/internal/providers/gcloud/grant_test.go new file mode 100644 index 00000000..a49f4f05 --- /dev/null +++ b/internal/providers/gcloud/grant_test.go @@ -0,0 +1,77 @@ +package gcloud + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func TestConfigFromCredential(t *testing.T) { + cred := &provider.Credential{ + Provider: "gcloud", + Token: "", + Metadata: map[string]string{ + MetaKeyProject: "my-proj", + MetaKeyScopes: "https://www.googleapis.com/auth/cloud-platform", + MetaKeyImpersonate: "sa@my-proj.iam.gserviceaccount.com", + MetaKeyKeyFile: "", + }, + } + cfg, err := ConfigFromCredential(cred) + if err != nil { + t.Fatalf("ConfigFromCredential: %v", err) + } + if cfg.ProjectID != "my-proj" { + t.Errorf("ProjectID = %q", cfg.ProjectID) + } + if cfg.ImpersonateSA != "sa@my-proj.iam.gserviceaccount.com" { + t.Errorf("ImpersonateSA = %q", cfg.ImpersonateSA) + } + if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/cloud-platform" { + t.Errorf("Scopes = %v", cfg.Scopes) + } +} + +func TestConfigFromCredentialDefaultScope(t *testing.T) { + cred := &provider.Credential{ + Provider: "gcloud", + Metadata: map[string]string{MetaKeyProject: "p"}, + } + cfg, _ := ConfigFromCredential(cred) + if len(cfg.Scopes) == 0 { + t.Error("expected default scope when none specified") + } +} + +func TestConfigFromCredentialMissingProject(t *testing.T) { + cred := &provider.Credential{Provider: "gcloud", Metadata: map[string]string{}} + _, err := ConfigFromCredential(cred) + if err == nil { + t.Error("expected error when project is missing") + } +} + +func TestSplitScopes(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"", 0}, + {"https://www.googleapis.com/auth/cloud-platform", 1}, + {"scope1,scope2,scope3", 3}, + {"scope1, scope2 , scope3", 3}, + } + for _, tt := range tests { + got := splitScopes(tt.input) + if len(got) != tt.want { + t.Errorf("splitScopes(%q) = %d scopes, want %d", tt.input, len(got), tt.want) + } + } +} + +func TestConfigFromCredentialNil(t *testing.T) { + _, err := ConfigFromCredential(nil) + if err == nil { + t.Error("expected error for nil credential") + } +} diff --git a/internal/providers/gcloud/provider.go b/internal/providers/gcloud/provider.go new file mode 100644 index 00000000..9f117e5b --- /dev/null +++ b/internal/providers/gcloud/provider.go @@ -0,0 +1,61 @@ +package gcloud + +import ( + "context" + "net/http" + + "github.com/majorcontext/moat/internal/provider" +) + +// Provider implements provider.CredentialProvider and provider.EndpointProvider +// for Google Cloud credentials via GCE metadata server emulation. +type Provider struct{} + +// Compile-time interface assertions. +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.EndpointProvider = (*Provider)(nil) +) + +// New creates a new gcloud provider. +func New() *Provider { return &Provider{} } + +func init() { provider.Register(New()) } + +// Name returns the provider identifier. +func (p *Provider) Name() string { return "gcloud" } + +// Grant acquires Google Cloud credentials from the host environment. +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + return grant(ctx) +} + +// ConfigureProxy is a no-op for gcloud since it uses the metadata endpoint. +func (p *Provider) ConfigureProxy(pc provider.ProxyConfigurer, cred *provider.Credential) { + // No-op: gcloud uses metadata endpoint, not header injection. +} + +// ContainerEnv returns nil; env vars are set by the run manager. +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + // Env vars (GOOGLE_CLOUD_PROJECT, CLOUDSDK_CORE_PROJECT, etc.) are + // injected by the run manager. Metadata requests reach the proxy via + // HTTP_PROXY and are routed to the per-run gcloud handler. + return nil +} + +// ContainerMounts returns nil; gcloud doesn't require any mounts. +func (p *Provider) ContainerMounts(cred *provider.Credential, containerHome string) ([]provider.MountConfig, string, error) { + return nil, "", nil +} + +// Cleanup is a no-op for gcloud. +func (p *Provider) Cleanup(cleanupPath string) {} + +// ImpliedDependencies returns dependencies implied by gcloud grant. +func (p *Provider) ImpliedDependencies() []string { return []string{"gcloud"} } + +// RegisterEndpoints registers HTTP handlers for the metadata emulation. +func (p *Provider) RegisterEndpoints(mux *http.ServeMux, cred *provider.Credential) { + // Metadata emulation is served by a per-run handler attached to the + // RunContext at daemon-register time, not via this package-level mux. +} diff --git a/internal/providers/gcloud/provider_test.go b/internal/providers/gcloud/provider_test.go new file mode 100644 index 00000000..c5ea19ea --- /dev/null +++ b/internal/providers/gcloud/provider_test.go @@ -0,0 +1,28 @@ +package gcloud + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func TestProviderName(t *testing.T) { + p := New() + if p.Name() != "gcloud" { + t.Errorf("Name() = %q, want %q", p.Name(), "gcloud") + } +} + +func TestProviderRegistered(t *testing.T) { + if provider.Get("gcloud") == nil { + t.Error("gcloud provider not registered") + } +} + +func TestImpliedDependencies(t *testing.T) { + p := New() + deps := p.ImpliedDependencies() + if len(deps) != 1 || deps[0] != "gcloud" { + t.Errorf("ImpliedDependencies() = %v, want [gcloud]", deps) + } +} diff --git a/internal/providers/register.go b/internal/providers/register.go index c348a747..268dc7bc 100644 --- a/internal/providers/register.go +++ b/internal/providers/register.go @@ -9,6 +9,7 @@ import ( _ "github.com/majorcontext/moat/internal/providers/aws" // registers AWS provider _ "github.com/majorcontext/moat/internal/providers/claude" // registers Claude/Anthropic provider _ "github.com/majorcontext/moat/internal/providers/codex" // registers Codex/OpenAI provider + _ "github.com/majorcontext/moat/internal/providers/gcloud" // registers gcloud provider _ "github.com/majorcontext/moat/internal/providers/gemini" // registers Gemini/Google provider _ "github.com/majorcontext/moat/internal/providers/github" // registers GitHub provider _ "github.com/majorcontext/moat/internal/providers/graphite" // registers Graphite provider diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index ac512002..18b15a32 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -294,6 +294,7 @@ type RunContextData struct { RequestCheck RequestChecker PathRulesCheck PathRulesChecker AWSHandler http.Handler + GCloudHandler http.Handler CredStore CredentialStore KeepEngines map[string]*keeplib.Engine HostGateway string @@ -328,14 +329,16 @@ type Proxy struct { extraHeaders map[string][]extraHeader // host -> additional headers to inject responseTransformers map[string][]ResponseTransformer // host -> response transformers mu sync.RWMutex - ca *CA // Optional CA for TLS interception - logger RequestLogger // Optional request logger - authToken string // Optional auth token required for proxy access - policy string // "permissive" or "strict" - allowedHosts []hostPattern // parsed allow patterns for strict policy - requestChecker RequestChecker // per-host request rules checker - pathRulesChecker PathRulesChecker // checks if host has path-level rules - awsHandler http.Handler // Optional handler for AWS credential endpoint + ca *CA // Optional CA for TLS interception + logger RequestLogger // Optional request logger + authToken string // Optional auth token required for proxy access + policy string // "permissive" or "strict" + allowedHosts []hostPattern // parsed allow patterns for strict policy + requestChecker RequestChecker // per-host request rules checker + pathRulesChecker PathRulesChecker // checks if host has path-level rules + awsHandler http.Handler // Optional handler for AWS credential endpoint + gcloudHandler http.Handler // Optional handler for gcloud metadata emulation + gcloudDirectResolver func() http.Handler // Resolves gcloud handler for direct (non-proxied) metadata requests credStore CredentialStore mcpServers []MCPServerConfig removeHeaders map[string][]string // host -> []headerName @@ -411,6 +414,21 @@ func (p *Proxy) SetAWSHandler(h http.Handler) { p.awsHandler = h } +// SetGCloudHandler sets the handler for gcloud metadata requests. +func (p *Proxy) SetGCloudHandler(h http.Handler) { + p.gcloudHandler = h +} + +// SetGCloudDirectResolver sets a resolver for direct (non-proxied) metadata +// requests. Google client libraries and the gcloud CLI use Python's bare +// http.client for metadata detection, which does NOT respect HTTP_PROXY. +// When GCE_METADATA_HOST points directly at the proxy, these arrive as +// direct requests without Proxy-Authorization. The resolver finds the +// appropriate gcloud handler by iterating registered runs. +func (p *Proxy) SetGCloudDirectResolver(fn func() http.Handler) { + p.gcloudDirectResolver = fn +} + // SetMCPServers configures MCP servers for credential injection. func (p *Proxy) SetMCPServers(servers []MCPServerConfig) { p.mcpServers = servers @@ -993,6 +1011,28 @@ func (p *Proxy) getAWSHandlerForRequest(r *http.Request) http.Handler { return p.awsHandler } +// getGCloudHandlerForRequest returns the gcloud handler from RunContextData +// if available, otherwise falls back to the proxy-level handler. +func (p *Proxy) getGCloudHandlerForRequest(r *http.Request) http.Handler { + if rc := getRunContext(r); rc != nil && rc.GCloudHandler != nil { + return rc.GCloudHandler + } + return p.gcloudHandler +} + +// resolveDirectGCloudHandler finds a gcloud handler for direct (non-proxied) +// metadata requests. Falls back to the proxy-level handler, then tries the +// direct resolver (which searches the daemon registry). +func (p *Proxy) resolveDirectGCloudHandler() http.Handler { + if p.gcloudHandler != nil { + return p.gcloudHandler + } + if p.gcloudDirectResolver != nil { + return p.gcloudDirectResolver() + } + return nil +} + // handleDirectMCPRelay handles MCP relay requests that arrive directly (not through proxy). // URL format: /mcp/{token}/{server-name}[/path] // Extracts the auth token from the URL, resolves run context, rewrites the path @@ -1082,6 +1122,20 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Direct GCE metadata requests from containers (via GCE_METADATA_HOST). + // Python's google-auth library uses bare http.client for metadata detection, + // which does NOT respect HTTP_PROXY. When the container's GCE_METADATA_HOST + // points at the proxy, these arrive as direct requests without Proxy-Authorization. + // We route them to the gcloud handler without requiring proxy auth. + if r.URL.Host == "" && r.Header.Get("Metadata-Flavor") == "Google" && + (r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/computeMetadata/")) { + h := p.resolveDirectGCloudHandler() + if h != nil { + h.ServeHTTP(w, r) + return + } + } + // Authentication and context resolution. // When a contextResolver is set (daemon mode), extract the proxy auth token, // resolve it to per-run context data, and store it in the request context. @@ -1104,6 +1158,20 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Handle gcloud metadata emulation. + // Google client libraries make plain HTTP requests to metadata.google.internal + // (or 169.254.169.254) via HTTP_PROXY. The proxy receives these as proxied GETs + // with the host set. Route them to the per-run gcloud handler. + // For 169.254.169.254, also check the path to avoid intercepting AWS IMDS + // requests when both gcloud and aws grants are active on the same run. + if r.Host == "metadata.google.internal" || + ((r.Host == "169.254.169.254" || strings.HasPrefix(r.Host, "169.254.169.254:")) && strings.HasPrefix(r.URL.Path, "/computeMetadata/")) { + if h := p.getGCloudHandlerForRequest(r); h != nil { + h.ServeHTTP(w, r) + return + } + } + // Handle AWS credential endpoint if awsH := p.getAWSHandlerForRequest(r); awsH != nil && strings.HasPrefix(r.URL.Path, "/_aws/credentials") { awsH.ServeHTTP(w, r) diff --git a/internal/proxy/proxy_gcloud_test.go b/internal/proxy/proxy_gcloud_test.go new file mode 100644 index 00000000..97a537d7 --- /dev/null +++ b/internal/proxy/proxy_gcloud_test.go @@ -0,0 +1,135 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGCloudMetadataRouting(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Metadata-Flavor", "Google") + w.WriteHeader(200) + }) + + // Create proxy with gcloud handler configured. + p := NewProxy() + p.SetGCloudHandler(mock) + + // Simulate a proxied GET from container to metadata.google.internal. + req := httptest.NewRequest("GET", "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", nil) + req.Header.Set("Metadata-Flavor", "Google") + req.Host = "metadata.google.internal" + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if !called { + t.Errorf("expected gcloud handler to be called, got status %d", w.Code) + } +} + +func TestGCloudMetadataRoutingIP(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(200) + }) + + p := NewProxy() + p.SetGCloudHandler(mock) + + req := httptest.NewRequest("GET", "http://169.254.169.254/computeMetadata/v1/project/project-id", nil) + req.Header.Set("Metadata-Flavor", "Google") + req.Host = "169.254.169.254" + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if !called { + t.Errorf("expected gcloud handler to be called for IP, got status %d", w.Code) + } +} + +func TestGCloudMetadataNotRoutedWithoutHandler(t *testing.T) { + p := NewProxy() + + req := httptest.NewRequest("GET", "http://metadata.google.internal/computeMetadata/v1/project/project-id", nil) + req.Header.Set("Metadata-Flavor", "Google") + req.Host = "metadata.google.internal" + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + // Without a handler, should not be 200 (will fall through to normal proxy handling). + // Just ensure no panic. +} + +// TestGCloudDirectMetadataRouting tests that direct (non-proxied) metadata +// requests are routed to the gcloud handler. This simulates what happens when +// GCE_METADATA_HOST points at the proxy and Python's bare http.client connects +// directly without HTTP_PROXY. +func TestGCloudDirectMetadataRouting(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Metadata-Flavor", "Google") + w.WriteHeader(200) + }) + + p := NewProxy() + p.SetGCloudDirectResolver(func() http.Handler { return mock }) + + // Direct request: r.URL.Host is empty (not a proxied request). + req := httptest.NewRequest("GET", "/computeMetadata/v1/project/project-id", nil) + req.Header.Set("Metadata-Flavor", "Google") + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if !called { + t.Errorf("expected gcloud direct handler to be called, got status %d", w.Code) + } +} + +// TestGCloudDirectPingRouting tests that the GCE detection ping (GET /) +// is handled for direct requests. +func TestGCloudDirectPingRouting(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Metadata-Flavor", "Google") + w.WriteHeader(200) + }) + + p := NewProxy() + p.SetGCloudDirectResolver(func() http.Handler { return mock }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Metadata-Flavor", "Google") + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if !called { + t.Errorf("expected gcloud direct handler to be called for ping, got status %d", w.Code) + } +} + +// TestGCloudDirectNotRoutedWithoutFlavor tests that direct requests without +// Metadata-Flavor header are not routed to gcloud. +func TestGCloudDirectNotRoutedWithoutFlavor(t *testing.T) { + var called bool + mock := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + p := NewProxy() + p.SetGCloudDirectResolver(func() http.Handler { return mock }) + + req := httptest.NewRequest("GET", "/computeMetadata/v1/project/project-id", nil) + // No Metadata-Flavor header + w := httptest.NewRecorder() + p.ServeHTTP(w, req) + + if called { + t.Error("gcloud handler should not be called without Metadata-Flavor header") + } +} diff --git a/internal/run/manager.go b/internal/run/manager.go index 96f7b36c..d6047b97 100644 --- a/internal/run/manager.go +++ b/internal/run/manager.go @@ -41,6 +41,7 @@ import ( _ "github.com/majorcontext/moat/internal/providers" // register all credential providers awsprov "github.com/majorcontext/moat/internal/providers/aws" "github.com/majorcontext/moat/internal/providers/claude" // only for settings types (LoadAllSettings, Settings, MarketplaceConfig) - provider setup uses provider interfaces + gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" "github.com/majorcontext/moat/internal/routing" "github.com/majorcontext/moat/internal/runctx" "github.com/majorcontext/moat/internal/secrets" @@ -654,7 +655,7 @@ func (m *Manager) Create(ctx context.Context, opts Options) (*Run, error) { } // Handle AWS endpoint provider - if ep := provider.GetEndpoint(string(credName)); ep != nil { + if string(credName) == "aws" { // AWS credentials are handled via credential endpoint // Parse stored config from Metadata (new format) with fallback to Scopes (legacy) awsCfg, err := awsprov.ConfigFromCredential(provCred) @@ -689,6 +690,31 @@ func (m *Manager) Create(ctx context.Context, opts Options) (*Run, error) { Profile: awsCfg.Profile, } } + + // Handle gcloud credential provider + if string(credName) == "gcloud" { + gcloudCfg, err := gcloudprov.ConfigFromCredential(provCred) + if err != nil { + return nil, fmt.Errorf("parsing gcloud credential: %w", err) + } + + gcloudCP, err := gcloudprov.NewCredentialProvider(ctx, gcloudCfg) + if err != nil { + return nil, fmt.Errorf("creating gcloud credential provider: %w", err) + } + r.GCloudCredentialProvider = gcloudCP + + // Store config for daemon registration so the daemon can + // create its own credential provider. + runCtx.GCloudConfig = &daemon.GCloudConfig{ + ProjectID: gcloudCfg.ProjectID, + Scopes: gcloudCfg.Scopes, + ImpersonateSA: gcloudCfg.ImpersonateSA, + KeyFile: gcloudCfg.KeyFile, + Email: gcloudCfg.Email, + Profile: credential.ActiveProfile, + } + } } } @@ -1000,6 +1026,32 @@ region = %s fmt.Printf("AWS credential_process configured (role: %s)\n", filepath.Base(r.AWSCredentialProvider.RoleARN())) } + + // Set up gcloud metadata emulation env vars + if r.GCloudCredentialProvider != nil { + email := r.GCloudCredentialProvider.Email() + if email == "" { + email = gcloudprov.DefaultEmail + } + proxyEnv = append(proxyEnv, + "GOOGLE_CLOUD_PROJECT="+r.GCloudCredentialProvider.ProjectID(), + "CLOUDSDK_CORE_PROJECT="+r.GCloudCredentialProvider.ProjectID(), + // Point metadata env vars directly at the proxy so that + // GCE detection can reach the metadata emulator without + // going through HTTP_PROXY. Two env vars are needed: + // GCE_METADATA_HOST — used by google-auth (client libraries) + // GCE_METADATA_ROOT — used by gcloud CLI (ReadNoProxy) + // Both explicitly bypass HTTP_PROXY, so they must point + // directly at the proxy's host:port. + "GCE_METADATA_HOST="+proxyHost, + "GCE_METADATA_ROOT="+proxyHost, + // Set the active account so gcloud CLI doesn't bail with + // "no active account selected" before trying metadata. + "CLOUDSDK_CORE_ACCOUNT="+email, + ) + fmt.Printf("gcloud metadata emulation configured (project: %s)\n", + r.GCloudCredentialProvider.ProjectID()) + } } // Set up SSH agent proxy for SSH grants (e.g., git clone git@github.com:...) @@ -3752,6 +3804,7 @@ func buildRegisterRequest(rc *daemon.RunContext, grants []string) daemon.Registe MCPServers: rc.MCPServers, Grants: grants, AWSConfig: rc.AWSConfig, + GCloudConfig: rc.GCloudConfig, } for host, creds := range rc.Credentials { diff --git a/internal/run/run.go b/internal/run/run.go index 686a797d..70ed3a8c 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -15,6 +15,7 @@ import ( "github.com/majorcontext/moat/internal/id" "github.com/majorcontext/moat/internal/provider" awsprov "github.com/majorcontext/moat/internal/providers/aws" + gcloudprov "github.com/majorcontext/moat/internal/providers/gcloud" "github.com/majorcontext/moat/internal/snapshot" "github.com/majorcontext/moat/internal/sshagent" "github.com/majorcontext/moat/internal/storage" @@ -98,6 +99,9 @@ type Run struct { // AWS credential provider (set when using aws grant) AWSCredentialProvider *awsprov.CredentialProvider + // gcloud credential provider (set when using gcloud grant) + GCloudCredentialProvider *gcloudprov.CredentialProvider + // awsTempDir is the temp directory for AWS credential helper (cleaned up on destroy) awsTempDir string