Skip to content

feat(grant): store keys for Anthropic-compatible gateways - #457

Merged
dpup merged 4 commits into
mainfrom
feat/anthropic-gateway-grant
Sep 2, 2026
Merged

feat(grant): store keys for Anthropic-compatible gateways#457
dpup merged 4 commits into
mainfrom
feat/anthropic-gateway-grant

Conversation

@dpup

@dpup dpup commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #456. That PR is the claude.base_url fix this builds on; gh pr create targets main, so the diff here includes its two commits until it merges. Review/merge #456 first — the new commit is ec7c76c.

Summary

A gateway that serves the Anthropic Messages API (LunaRoute, an internal router) issues its own keys, so moat grant anthropic rejected them outright: the sk-ant- prefix check fails, and validation went to api.anthropic.com, which knows nothing about them. The only way to use such a gateway was to export its key into the container via env/secrets — where the agent can read it and the proxy logs it in cleartext.

moat grant anthropic --base-url https://gw.lunaroute.com --profile lunaroute
moat run --profile lunaroute -- claude     # in any project

What --base-url does

  • Skips the sk-ant- prefix check — gateways issue keys in their own format.
  • Validates against <url>/v1/messages, failing only on 401/403. A gateway serves its own model catalog, so the fixed validation model is usually unknown to it and the request comes back 400/404 — which still proves the key authenticated. Being stricter would reject working keys on every gateway that doesn't happen to serve Anthropic's model ids.
  • Sends the key as x-api-key, the same header moat injects at runtime, so a Bearer-only gateway fails at grant time rather than at the first real request.
  • Records the endpoint on the credential (Metadata["base_url"]), so runs resolve it with no per-project config. moat.yaml's claude.base_url still wins.

The security bit worth reviewing

The gateway key is deliberately not registered for api.anthropic.com. Claude Code contacts that host regardless of ANTHROPIC_BASE_URL — telemetry, the bootstrap check, the MCP registry — so injecting a third-party key into those requests would hand it to Anthropic. AnthropicProvider.ConfigureProxy now returns early for a gateway credential, and TestAnthropicConfigureProxy_GatewayKeyNotSentToAnthropic guards it (with the companion case asserting a plain key is still injected).

Verified end to end against a local gateway stand-in that only accepts one key. From the run's network.jsonl:

('GET',  'https://api.anthropic.com/api/claude_cli/bootstrap', 401, 'moat-proxy-injected')
('POST', 'https://api.anthropic.com/api/eval/sdk-...',         200, 'moat-proxy-injected')
('POST', 'http://moat-host:8788/v1/messages',                  200, '[REDACTED]')

The gateway got the real key (hello from the gateway came back through the agent); Anthropic's own API only ever saw the placeholder. The injected header is redacted in the log, which also sidesteps the cleartext-logging exposure the secrets: route has.

Also verified: wrong key → 401 … rejected the key; --base-url on another provider → actionable error; ftp:// → rejected; moat grant list shows api-key (gateway); moat grant show prints the endpoint.

Confirmed LunaRoute accepts the header moat injects — with no auth it answers Provide your LunaRoute API key via the LUNAROUTE-API-KEY header, Authorization: Bearer, or x-api-key.

Also included

  • examples/lunaroute/ — working example using the grant + profile flow.
  • Guide, CLI reference, and grants reference updated; docs/content/guides/01-claude-code.md gains an "Anthropic-compatible gateway" section.

make lint clean, make test-unit green.

Out of scope

  • moat doctor claude still diagnoses against api.anthropic.com, so it will report no LLM traffic for a gateway run. Worth a follow-up.
  • Profile-level env in ~/.moat/config.yaml (so the model names live with the profile instead of each moat.yaml) is the next PR in this series.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 11m 35s —— View job


Review: feat(grant): store keys for Anthropic-compatible gateways

  • Gather context (diff, changed files, CLAUDE.md conventions)
  • Run /code-review skill against the PR diff
  • Verify findings by building/testing/vetting locally
  • Post final review summary

Note: this branch is stacked on #456; origin/main...HEAD includes that PR's two commits (8548317, 63926c4). I focused this review on the new commit ec7c76c ("feat(grant): store keys for Anthropic-compatible gateways") and the changelog fixup 72ec721, since #456 should be reviewed on its own.


go build ./..., go vet ./..., gofmt -l, and targeted go test (internal/run, internal/providers/claude, cmd/moat/cli, internal/config, internal/credential) all pass clean.

The core design is solid: the security-sensitive part — AnthropicProvider.ConfigureProxy skipping the api.anthropic.com injection for a gateway-scoped credential (internal/providers/claude/provider.go:99-104) — is correct and has good companion-case coverage (TestAnthropicConfigureProxy_GatewayKeyNotSentToAnthropic / ..._PlainKeyStillInjected). resolveClaudeBaseURL (internal/run/baseurl.go) and its loopback-rewrite handling are well tested (554 lines across 12 test functions covering errors, loopback rewriting, credential-vs-config precedence).

Posted 4 inline comments:

  1. docs/content/reference/02-moat-yaml.md:1242 — stale doc: still says the grant's credential is injected for the base_url host "in addition to" the standard api.anthropic.com injection, which is no longer true for a gateway credential after this PR's ConfigureProxy change. This reference page wasn't updated alongside the guide/CLI docs.
  2. cmd/moat/cli/grant.go:140 — the CLI wiring for --base-url (wrong-provider error, gateway display in grant_list/grant_show) has no test coverage, unlike the well-tested core logic underneath it. Matches CLAUDE.md's "test the companion case" invariant.
  3. internal/providers/claude/grant_baseurl.go:36 (minor) — the same URL-format check (scheme must be http/https, host != "") is now duplicated in three places (config.go, grant_baseurl.go, run/baseurl.go); a shared helper would make the doc comment's "cannot disagree" claim structural rather than a promise to keep in sync by hand.
  4. internal/providers/claude/anthropic_auth.go:152 (minor) — ValidateGatewayKey duplicates ~15 lines of request-building from ValidateKey just above it; a shared helper would avoid the two drifting apart.

One thing I couldn't leave as an inline comment (it's on unchanged code, so GitHub rejected the anchor) but wanted to flag: internal/run/manager_create.go's pre-existing MCP-credential loop (around line 462-472) calls SetCredentialWithGrant(serverHost, ..., grant) for any grant matching a top-level mcp: entry's auth.grant, before prov.ConfigureProxy runs. If a moat.yaml ever defined an mcp: entry pointing at api.anthropic.com with auth: {grant: anthropic}, it would inject a gateway-scoped anthropic credential for that host — the exact leak this PR's ConfigureProxy early-return was written to prevent, via a different code path that early-return doesn't touch. This requires a fairly contrived config (an MCP entry explicitly targeting api.anthropic.com) and isn't new to this PR, so I'm not confident it's a real gap — but given how carefully this PR guards against leaking the gateway key to Anthropic elsewhere, it seemed worth the author confirming this path can't reintroduce it.

Everything else — credential metadata plumbing, grantViaAPIKey's branch for gateway vs. plain keys, the LunaRoute example, and the docs that were updated — looks correct and matches the stated behavior.
· branch

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 4m 25s —— View job


Claude finished reviewing this PR

  • Gather context (read diff, changed files, CLAUDE.md)
  • Understand the request (code review via /code-review skill)
  • Run /code-review skill against the PR diff
  • Verify with go build, go vet, gofmt, targeted go test
  • Post final summary

Scope reviewed

Per the PR description, this PR is stacked on #456; I scoped the review to this PR's own commits (63926c4..HEAD, i.e. ec7c76c + the changelog fill), not the already-merged-elsewhere claude.base_url fix.

Verification

  • go build ./... — clean
  • go vet ./... — clean
  • gofmt -l on all changed .go files — clean
  • go test ./internal/providers/claude/... ./internal/run/... ./cmd/moat/cli/... — all pass
  • golangci-lint not installed in this environment, so I couldn't run it (fell back to go vet, per CLAUDE.md's documented fallback)

Findings

No bugs, security issues, or missing test coverage found. Specifically checked:

  • The security-critical bit: AnthropicProvider.ConfigureProxy (internal/providers/claude/provider.go:97-102) correctly early-returns for a gateway credential (Metadata[MetaKeyBaseURL] != ""), so a gateway key is never registered for api.anthropic.com. TestAnthropicConfigureProxy_GatewayKeyNotSentToAnthropic plus its companion TestAnthropicConfigureProxy_PlainKeyStillInjected (provider_test.go) cover both directions, matching the "test the companion case" invariant in CLAUDE.md.
  • Precedence rule (moat.yaml claude.base_url wins over the credential's recorded endpoint): implemented in claudeBaseURLSource (internal/run/baseurl.go:90-98) and covered by TestClaudeBaseURLSource, which exercises all four combinations (neither/config-only/cred-only/both).
  • Validation parity: ValidateBaseURL (grant_baseurl.go) mirrors the scheme/host checks in config.Load's claude.base_url validation exactly (both require http/https scheme and non-empty host) — no drift between the two entry points. resolveClaudeBaseURL additionally re-validates the scheme when reading a stored credential's endpoint back out, since that's the one path that was previously unvalidated at that point — nice catch, with a dedicated test (TestConfigureClaudeBaseURLRejectsBadCredentialURL).
  • Gateway key validation (ValidateGatewayKey, anthropic_auth.go): sends x-api-key (not Authorization), treats only 401/403 as failure, discards the response body without leaking it into error messages. Table-driven test covers accepted/400/404/429/401/403 plus a trailing-slash case and an unreachable-endpoint case.
  • CLI wiring (cmd/moat/cli/grant.go): --base-url is rejected for any provider other than anthropic with an actionable error; grant_list.go/grant_show.go surface the gateway state (api-key (gateway), Endpoint:) so it's not visually indistinguishable from a real Anthropic key grant.
  • Docs: guide, CLI reference, and grants reference all updated and consistent with the actual flag behavior (spot-checked against the code, not just read).

This is a clean, well-scoped PR — the security reasoning is spelled out in code comments where it matters (provider.go, baseurl.go), and every behavioral claim in the description has a matching test. Nothing to request changes on.
· branch feat/anthropic-gateway-grant

Comment thread docs/content/reference/02-moat-yaml.md Outdated
- Scheme must be `http` or `https`

Moat routes traffic through a relay endpoint on the Moat proxy, which forwards requests to the configured URL with credentials injected. This works transparently with `localhost` URLs because the relay runs on the host where `localhost` resolves correctly. Credentials from the `anthropic` or `claude` grant are injected for the base URL host in addition to the standard `api.anthropic.com` injection.
Traffic goes through the Moat proxy, which injects the `anthropic` or `claude` grant's credential for the endpoint's host in addition to the standard `api.anthropic.com` injection. The key stays on the host; the container only ever holds a placeholder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line is now stale for the case this PR adds. It says:

Traffic goes through the Moat proxy, which injects the anthropic or claude grant's credential for the endpoint's host in addition to the standard api.anthropic.com injection.

But AnthropicProvider.ConfigureProxy (internal/providers/claude/provider.go:99-104) now returns early — skipping the api.anthropic.com injection — whenever the credential has Metadata[base_url] set (a gateway key from moat grant anthropic --base-url). That's the whole point of this PR's security fix, but this reference page (not touched by the diff) still describes the old "both hosts get the key" behavior unconditionally.

Worth a one-line carve-out here, similar to what docs/content/guides/01-claude-code.md and docs/content/reference/01-cli.md already got in this PR.

Fix this →

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 9f8a449. The page now says the credential is injected for the endpoint's host, then carves out the gateway case explicitly:

An ordinary Anthropic key is injected for api.anthropic.com as well. A gateway key — one granted with moat grant anthropic --base-url — is deliberately not: Claude Code contacts api.anthropic.com regardless of ANTHROPIC_BASE_URL, and a third-party key has no business being sent there.

Comment thread cmd/moat/cli/grant.go
// --base-url marks the key as belonging to an Anthropic-compatible gateway
// rather than to Anthropic. Only the anthropic provider knows what to do
// with it; anywhere else it would be silently ignored.
if grantBaseURL != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The core gateway-key logic (ValidateBaseURL, ConfigureProxy's skip, resolveClaudeBaseURL) is well covered in internal/providers/claude/grant_baseurl_test.go, provider_test.go, and internal/run/baseurl_test.go. But this CLI wiring — the --base-url-on-wrong-provider error at line 142, the ValidateBaseURL error wrap, and the new gateway-display branches in grant_list.go's credType and grant_show.go's showProviderMetadata — has no test coverage (grant_test.go/grant_show_test.go don't exercise the gateway path, and there's no grant_list_test.go at all).

Per this repo's CLAUDE.md invariant #1 ("test the companion case"), it'd be good to add at least:

  • a runGrant-level test asserting --base-url with a non-anthropic provider errors
  • a credType/showProviderMetadata test asserting the gateway credential renders as api-key (gateway) / prints Endpoint:

Not a blocker given the underlying logic is solid, but worth adding before this pattern gets copied elsewhere.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 9f8a449, all with companion cases:

  • TestGrantBaseURLWrongProvider--base-url against github/claude/openai/npm all error with guidance naming the anthropic grant.
  • TestGrantBaseURLInvalidURLftp://, no scheme, http://, http://:8080 all error, and the message names the flag.
  • TestCredTypeGateway — gateway renders api-key (gateway); plain key and empty-base_url metadata both stay api-key.
  • TestShowProviderMetadataGatewayEndpoint — prints Endpoint:; companion asserts a plain key prints no empty label.

//
// The returned URL has any trailing slash removed, since the path is joined
// onto it later.
func ValidateBaseURL(raw string) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the "scheme must be http/https, host non-empty" check is now implemented three times independently — here, in internal/config/config.go's claude.base_url validation, and again in internal/run/baseurl.go's resolveClaudeBaseURL (which re-checks on purpose since it also validates a credential-sourced URL that bypasses the other two — see the comment at internal/run/baseurl.go:114-117, which is a legitimate reason for that one to exist independently).

The doc comment here even says it "mirrors the moat.yaml claude.base_url rules so the two sources cannot disagree" — but that's only true as long as someone remembers to keep both copies in sync by hand. A small shared helper (e.g. config.ValidateHTTPURL(raw string) (*url.URL, error)) that all three call would make that guarantee structural instead of a comment. Not urgent, just flagging since CLAUDE.md's review checklist calls out "duplicated code that should be shared or consolidated."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, done in 9f8a449. All three now call config.ValidateHTTPURL, so the guarantee is structural instead of a comment.

It also paid for itself immediately: the http://:8080 empty-hostname gap you flagged on #456 was fixed in two of the three copies, and consolidating closed the third.

// The key is sent as x-api-key, the same header moat injects at runtime for an
// anthropic credential, so a gateway that only accepts Bearer tokens fails here
// rather than at the first real request.
func (a *anthropicAuth) ValidateGatewayKey(ctx context.Context, apiKey, baseURL string) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ValidateGatewayKey duplicates the request-building block from ValidateKey right above it (same reqBody template, Content-Type, x-api-key, anthropic-version headers — only the endpoint and status handling differ). If the validation payload changes later (new header, different probe model), it's easy to update one and miss the other, and gateway-key validation would silently drift from Anthropic-key validation. Consider factoring the request construction into a shared helper that both call.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 9f8a449 — both now build the probe through newAPIKeyProbe(ctx, endpoint, apiKey), so the payload and headers can't drift between Anthropic-key and gateway-key validation.

@dpup
dpup force-pushed the feat/anthropic-gateway-grant branch from 72ec721 to 9f8a449 Compare September 2, 2026 20:25
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 9m 26s —— View job


Review complete

  • Gather context (PR diff, existing review comments, CLAUDE.md)
  • Confirm the four findings from the prior review round are resolved in 9f8a449
  • Run /code-review skill against the full diff (multiple angles: correctness, cross-file tracing, reuse/duplication, CLAUDE.md conventions, altitude/bandaid check)
  • Verify surviving findings directly against the code
  • go build ./..., go vet ./..., and targeted go test for all touched packages — all green

Prior review round — all four resolved

Commit 9f8a449 cleanly fixes everything flagged in the previous pass: docs/content/reference/02-moat-yaml.md now carves out the gateway case, config.ValidateHTTPURL is now the single shared implementation used by all three endpoint-validation call sites (config.Load, ValidateBaseURL, resolveClaudeBaseURL), newAPIKeyProbe unifies the ValidateKey/ValidateGatewayKey request building, and grant_test.go / grant_gateway_display_test.go now cover the CLI wiring (--base-url on the wrong provider, an invalid URL, and both display branches) with companion cases throughout. Nice fix.

New findings (posted inline)

  1. internal/providers/claude/provider.go:149 (ConfigureBaseURLProxy) — no guard against the resolved claude.base_url host being api.anthropic.com. AnthropicProvider.ConfigureProxy skips injecting a gateway credential into api.anthropic.com, but ConfigureBaseURLProxy doesn't mirror that check. Since claudeBaseURLSource lets moat.yaml's claude.base_url win over the credential's own recorded endpoint, a project that sets claude.base_url: https://api.anthropic.com while a gateway-flavored anthropic credential is active would inject the gateway key into api.anthropic.com — the exact scenario this PR's security section says is prevented.
  2. internal/run/baseurl.go:142 — trailing-slash normalization is applied on the --base-url grant path (ValidateBaseURL trims it) but not on the moat.yaml claude.base_url path (resolveClaudeBaseURL passes raw through unchanged), so the same endpoint produces a different ANTHROPIC_BASE_URL depending on which of the two ways it was configured.

Additional findings (can't inline — outside the diff)

  1. internal/daemon/persist.go's resolveCredentials (unchanged by this PR) carries an explicit "must stay in sync with run/manager.go Create()" warning, but wasn't updated for the new configureClaudeBaseURL/ConfigureBaseURLProxy step. Combined with AnthropicProvider.ConfigureProxy now skipping api.anthropic.com for gateway credentials, a run using a gateway grant that survives a daemon restart (crash, moat proxy restart, binary upgrade) gets zero credential injection anywhere afterward — not api.anthropic.com (deliberately skipped) and not the gateway host (never re-registered on restore). Worth a follow-up.
  2. Pi provider cross-feature break: Pi has no credential of its own and relies entirely on AnthropicProvider.ConfigureProxy injecting into api.anthropic.com via the shared anthropic grant (internal/providers/pi/provider.go). Since a gateway-flavored anthropic credential now skips that injection, and Pi's NetworkHosts() only allowlists api.anthropic.com/api.openai.com (no notion of claude.base_url), granting moat grant anthropic --base-url ... silently breaks moat pi --provider anthropic with an opaque 401 rather than a clear error.

Minor / optional (not blocking)

  • claudeBaseURLSource + internal/run/manager_create.go's grant-processing loop pick anthropicCred by "last matching grant in opts.Grants wins" — pre-existing ambiguity (predates this PR), but now has a sharper failure mode: --grant anthropic --grant claude in that order silently drops the gateway base_url routing with no warning, since the OAuth credential wins and carries no endpoint metadata.
  • isLoopbackHost (internal/run/baseurl.go:173) is case-sensitive ("LOCALHOST" isn't recognized), and duplicates config.isHostLocalURL's pre-existing loopback check with slightly different fidelity — worth consolidating at some point.
  • hasUserSuppliedAnthropicKey treats an empty-string ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN value as "supplied," suppressing the "nothing provides its key" warning in exactly the case it'd be most useful.
  • grantViaAPIKey (internal/providers/claude/grant.go) still builds the provider.Credential{} literal twice (gateway vs. plain branch) — small, not urgent.

Nothing here is a blocker on the core gateway-key security property for the primary documented flow (moat grant anthropic --base-url ... with no conflicting moat.yaml override, which is exactly what the PR's own end-to-end verification exercised); the two inline findings are about secondary paths (an explicit moat.yaml override, and daemon restart) that weren't covered by the existing tests.

// AddExtraHeader, etc.) validate via isValidHost which rejects colons. The
// proxy's own lookup falls back from host:port to host, so a bare host
// still matches a request to a non-default port.
host := baseURLHost

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ConfigureBaseURLProxy has no guard against baseURLHost resolving to api.anthropic.com, unlike AnthropicProvider.ConfigureProxy a few lines above (line 99-102 of this same diff), which explicitly skips injection for a gateway credential (cred.Metadata[MetaKeyBaseURL] != "").

This PR's whole security argument is "the gateway key is deliberately not registered for api.anthropic.com" (per the doc comment above and the CHANGELOG). But claudeBaseURLSource (internal/run/baseurl.go:106-114) lets moat.yaml's claude.base_url win over the credential's own recorded endpoint. If a project's moat.yaml sets claude.base_url: https://api.anthropic.com (by mistake, or via a shared/templated config) while a gateway-flavored anthropic credential is active, resolveClaudeBaseURL resolves the host to api.anthropic.com, and this function injects the gateway key there via x-api-key — exactly the outcome the PR's security section says can't happen.

Worth mirroring the same check here, e.g.:

if cred.Metadata[credential.MetaKeyBaseURL] != "" && host == "api.anthropic.com" {
    return
}

Fix this →

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 055bb1c — you're right, the guarantee had a hole exactly where you said.

ConfigureBaseURLProxy now refuses api.anthropic.com for a gateway credential, mirroring the guard in ConfigureProxy, with the host as a named constant (claude.APIHost) so the two can't drift. The run also warns rather than leaving an opaque 401, since pointing a gateway key at Anthropic is a config mistake worth naming:

Warning: claude.base_url points at api.anthropic.com, but the active anthropic grant is a gateway key for https://gw.lunaroute.com — it will not be sent to Anthropic.
  Remove claude.base_url to use the gateway, or run without this profile to use an Anthropic key

Three tests, so the guard can't be over- or under-broad: gateway key + Anthropic host is refused; gateway key + its own host is still injected; plain Anthropic key + Anthropic host is still injected. The reference doc now states it unconditionally instead of implying the credential path is the only route.

Comment thread internal/run/baseurl.go Outdated

host := u.Hostname()
if !isLoopbackHost(host) {
return claudeBaseURL{ContainerURL: raw, CredentialHost: host}, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trailing-slash normalization is applied on only one of the two claude.base_url entry points, so functionally-identical input produces different ANTHROPIC_BASE_URL values depending on where it came from.

ValidateBaseURL (internal/providers/claude/grant_baseurl.go:37-42, the --base-url grant path) does strings.TrimSuffix(raw, "/") before storing the credential's endpoint — its own doc comment says this matters "since the path is joined onto it later." But config.Load's claude.base_url validation (internal/config/config.go:742-746) calls ValidateHTTPURL and never trims, and here the non-loopback branch returns raw unchanged as ContainerURL.

So moat.yaml's claude.base_url: https://gw.example.com/ (trailing slash) ends up as ANTHROPIC_BASE_URL=https://gw.example.com/ in the container — if Claude Code or the gateway joins /v1/messages naively, that's a double slash — while the same endpoint granted via moat grant anthropic --base-url https://gw.example.com/ is normalized first. Given the trim already exists and the reasoning for it (ValidateHTTPURL's doc comment: "the three [sources] cannot drift into disagreeing about what a usable endpoint is"), it'd be more robust to fold the trim into config.ValidateHTTPURL itself (or have resolveClaudeBaseURL apply it to raw) so all three paths agree structurally rather than only two of three.

Fix this →

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 055bb1c, folded into config.ValidateHTTPURL as you suggested rather than patched at the third site.

It now returns the normalized string alongside the parsed URL, and all three entry points use it: config.Load normalizes cfg.Claude.BaseURL in place, ValidateBaseURL returns the normalized form (its own TrimSuffix is gone), and resolveClaudeBaseURL builds ContainerURL from it. strings.TrimRight rather than TrimSuffix, so https://gw.example.com/// also collapses.

Covered in both places: TestValidateHTTPURL asserts the normalized form for four trailing-slash inputs, and TestResolveClaudeBaseURLTrimsTrailingSlash checks the remote and loopback branches (the loopback one rebuilds the URL, so it needed its own case).

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 2m 45s —— View job


Review: feat(grant): store keys for Anthropic-compatible gateways

  • Gather context (diff since last review, CLAUDE.md conventions)
  • Check whether the two outstanding review findings were addressed by the latest commit
  • Run /code-review skill against the new changes
  • Verify findings with build/vet/test
  • Post final review summary

This is a re-review after synchronize (latest commit 055bb1c). All six findings from the previous two review rounds are now resolved:

From this round's most important finding (9f8a449055bb1c):

  • ConfigureBaseURLProxy api.anthropic.com guard — fixed correctly. internal/providers/claude/provider.go:165 now refuses to register a gateway credential for api.anthropic.com even when claude.base_url (which wins over the credential's own endpoint) explicitly names that host. The guard runs after the port-stripping logic (provider.go:154-157), so api.anthropic.com:443 is still caught correctly. internal/run/baseurl.go:64-70 adds a matching warning so the failure mode is a clear message rather than an opaque 401. Both directions are tested: TestConfigureBaseURLProxy_GatewayKeyRefusesAnthropicHost / ..._PlainKeyReachesAnthropicHost (provider_test.go) and TestConfigureClaudeBaseURLGatewayKeyNeverReachesAnthropic / ..._PlainKeyReachesAnthropic (baseurl_test.go).
  • Trailing-slash normalization drift — fixed by folding the trim into config.ValidateHTTPURL itself (now returns (*url.URL, string, error)), so all three entry points (moat.yaml, --base-url, and the credential-sourced resolveClaudeBaseURL) normalize identically. Verified all three call sites were updated consistently (config.go:744, grant_baseurl.go:37, run/baseurl.go:142) — no stale callers of the old 2-return signature. Covered by TestValidateHTTPURL's new normalization cases and TestResolveClaudeBaseURLTrimsTrailingSlash.

From the earlier round (9f8a449), also confirmed still in place:

  • docs/content/reference/02-moat-yaml.md gateway carve-out — present and now also mentions trailing-slash normalization.
  • CLI wiring test coverage (grant.go --base-url validation, credType/showProviderMetadata gateway display) — TestGrantBaseURLWrongProvider, TestGrantBaseURLInvalidURL, TestCredTypeGateway, TestShowProviderMetadataGatewayEndpoint all present with companion cases.
  • Shared ValidateHTTPURL helper — consolidated, no more triplicated scheme/host checks.
  • Shared probe builder for ValidateKey/ValidateGatewayKey — de-duplicated.

Verification performed:

  • go build ./..., go vet ./... — clean across the whole repo.
  • gofmt -l on all files touched by 055bb1c — clean.
  • go test ./internal/config/... ./internal/providers/claude/... ./internal/run/... ./cmd/moat/cli/... — all pass.
  • Confirmed CHANGELOG.md has no unfilled #NNN placeholder.

One low-confidence note, not blocking: the new guard in ConfigureBaseURLProxy (provider.go:165) does a case-sensitive host == APIHost comparison, and neither resolveClaudeBaseURL nor ValidateHTTPURL lowercase the hostname. A moat.yaml with claude.base_url: https://API.Anthropic.com (unusual casing, but valid syntax and DNS-equivalent to the real host) would bypass this specific guard, since the hardcoded-request path in AnthropicProvider.ConfigureProxy is unaffected (it never contacts a URL from config) but the ConfigureBaseURLProxy path would register the gateway key under the differently-cased host string. Whether that actually leaks the key depends on whether the downstream proxy's credential lookup does case-insensitive host matching — I didn't chase that into the gatekeeper module. Given this requires a user to deliberately type non-lowercase Anthropic's hostname while a gateway grant is active, it's a very narrow edge case similar in spirit to the MCP-path note from the first review round — worth a mental note, not a blocker.

No other issues found. The PR is in good shape — the security-critical property (a gateway key never reaching api.anthropic.com, however the endpoint is named) now holds structurally across both the standard and base_url injection paths, with tests guarding both directions.
· branch

dpup added 4 commits September 2, 2026 14:38
A gateway that serves the Anthropic Messages API (LunaRoute, an internal
router) issues its own keys, so `moat grant anthropic` rejected them: the
sk-ant- prefix check fails, and validation went to api.anthropic.com,
which knows nothing about them. The only way to use such a gateway was to
export its key into the container via env or secrets, where the agent can
read it and the proxy logs it in cleartext.

`moat grant anthropic --base-url <url>` stores one properly:

  - skips the sk-ant- prefix check
  - validates against <url>/v1/messages, failing only on 401/403 (a
    gateway serves its own catalog, so the validation model is usually
    unknown to it and a 400/404 still proves the key authenticated)
  - sends the key as x-api-key, the header moat injects at runtime, so a
    Bearer-only gateway fails at grant time rather than first request
  - records the endpoint on the credential

Runs then resolve their endpoint from the credential when moat.yaml does
not name one, so a gateway key works in any project with no per-project
config. moat.yaml's claude.base_url still wins.

The key is deliberately NOT registered for api.anthropic.com. Claude Code
contacts that host regardless of ANTHROPIC_BASE_URL — telemetry, the
bootstrap check, the MCP registry — and injecting a third-party key into
those requests would hand it to Anthropic. Verified against a local
gateway: those requests carry only the placeholder.

Pair with --profile to keep a gateway key separate from a real Anthropic
key. `moat grant list` marks it "api-key (gateway)" and `moat grant show`
prints the endpoint, since the two are not interchangeable.
Four review findings on the gateway grant.

The "http or https, with a host" rule was implemented three times —
moat.yaml's claude.base_url, --base-url, and the run-side resolver that
re-checks a credential-sourced endpoint. A comment promised they stayed
in sync by hand. They now all call config.ValidateHTTPURL, so that
guarantee is structural, and the empty-hostname case ("http://:8080")
is fixed everywhere at once rather than in two of three places.

ValidateGatewayKey duplicated ValidateKey's request building, so a
change to the probe payload or headers could silently apply to Anthropic
keys but not gateway keys. Both now build it through newAPIKeyProbe.

The CLI wiring had no tests: --base-url on a non-anthropic provider,
--base-url naming an unusable endpoint, and the two display branches
that keep a gateway key distinguishable from an Anthropic one in
`moat grant list` and `moat grant show`. Each is covered with its
companion case.

Finally, docs/content/reference/02-moat-yaml.md still said the
credential is injected "in addition to the standard api.anthropic.com
injection" — exactly what this branch stops doing for a gateway key.
Review found a hole in this branch's own security claim. The key is not
registered for api.anthropic.com by AnthropicProvider.ConfigureProxy —
but moat.yaml's claude.base_url wins over the endpoint recorded on the
credential, so a config naming api.anthropic.com while a gateway key is
active routed that key straight back to Anthropic through
ConfigureBaseURLProxy.

ConfigureBaseURLProxy now refuses that host for a gateway credential,
mirroring the guard above it, and the run warns instead of leaving an
opaque 401 — pointing at Anthropic with a gateway key is a
configuration mistake worth naming. A plain Anthropic key aimed at
api.anthropic.com is still injected; that is a legitimate setup.

Also from review: trailing-slash normalization ran on only one of the
three endpoint sources. --base-url trimmed, moat.yaml did not, so the
same gateway written with a trailing slash produced a different
ANTHROPIC_BASE_URL depending on where it came from, and a naive join
would yield "//v1/messages". The trim moved into config.ValidateHTTPURL,
which now returns the normalized form, so all three sources agree
structurally rather than two of three by hand.
@dpup
dpup force-pushed the feat/anthropic-gateway-grant branch from 055bb1c to 869b8fa Compare September 2, 2026 21:39
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dpup's task in 6m 42s —— View job


Review in progress

  • Gather context (diff since last review, prior review threads, CLAUDE.md)
  • Confirm fixes from prior rounds (commit 869b8fa) are present on this branch
  • Run /code-review skill against the current diff
  • Verify findings with build/vet/test
  • Post final review summary

@dpup
dpup merged commit d169fba into main Sep 2, 2026
4 checks passed
@dpup
dpup deleted the feat/anthropic-gateway-grant branch September 2, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant