Skip to content

fix(ai): resolve the Kimi usage base URL from trusted env only - #3280

Closed
10kH wants to merge 1 commit into
Yeachan-Heo:devfrom
10kH:fix/kimi-usage-baseurl-trust
Closed

fix(ai): resolve the Kimi usage base URL from trusted env only#3280
10kH wants to merge 1 commit into
Yeachan-Heo:devfrom
10kH:fix/kimi-usage-baseurl-trust

Conversation

@10kH

@10kH 10kH commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Companion to #3278 (Kimi OAuth host) — same provider, different file, kept separate so each stays independently reviewable.

Problem

// usage/kimi.ts:35
const envBase = $env.KIMI_CODE_BASE_URL?.trim();

Bun.env === process.env, and the env module folds the caller's cwd/.env into it. That base becomes the usage URL (buildUsageUrl at :40, used at :231), and the request sends the credential to it:

// :237
Authorization: `Bearer ${accessToken}`

So a repository could plant one line in .env and receive the user's Kimi access token.

Fix

Resolve through $credentialEnv, the resolver this codebase already designates for material that must not come from the caller's project .env. An explicit caller-supplied base URL still takes precedence, and the launching shell, ~/.env, the GJC config .env and the agent .env keep working.

Tests

kimi-usage-baseurl-trust.test.ts spawns a probe with a controlled cwd (the env module parses projectEnv at load time, so the boundary is only observable across processes): built-in base by default, a planted .env is ignored, an inherited value still applies, a planted .env cannot override an inherited one, and an explicit caller value stays ahead of the environment.

Proof-first: reverting the resolver fails exactly ignores a KIMI_CODE_BASE_URL planted by the project .env (4 pass / 1 fail).

Verification

bun run check in packages/ai (whole-package biome + tsc): exit 0. New suite: 5 pass / 0 fail. Kimi-related suites across the package: 232 pass / 0 fail.

normalizeBaseUrl() read KIMI_CODE_BASE_URL through $env, the merged view that
includes the caller's cwd/.env. That base becomes the usage URL
(buildUsageUrl at :40, used at :231) and the request sends
`Authorization: Bearer ${accessToken}` to it at :237, so repository content
could collect the user's Kimi access token.

Resolve through $credentialEnv, the resolver this codebase already uses for
material that must not come from the project .env. An explicit caller-supplied
base URL still takes precedence, and the launching shell plus GJC/user-owned
.env files keep working.

Regression: a child process with a controlled cwd (projectEnv is parsed at
module load) proves a planted .env cannot set the base, an inherited value still
applies, a planted .env cannot override it, and an explicit caller value stays
ahead of the environment. Reverting the resolver fails exactly the planted case.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Adversarial review — exact head e23e8c3d630e61f82a3bb350a67cca99d8d667d2 vs origin/dev 3649db42e4f31cb373c0cee5703fa4c0b0996680

Verdict: MERGE_READY (do not merge yet — see Merge gating.)

Reviewed the exact head. Continuation of the same read-only env-trust batch as #3273/#3276/#3278.

Blast radius — confirmed in code

normalizeBaseUrl()buildUsageUrl() → the fetch at :248, which sets Authorization: Bearer ${accessToken} (:251) using the credential read at :231. So the env value does become the destination for a request carrying the user's Kimi access token. The description is accurate.

Scope note worth stating plainly: this is an access token on a usage endpoint, where #3278 reaches the refresh token. Same class, genuinely smaller radius. Both are worth fixing; I would not have blocked either on the other.

Trust boundary — independently verified

Own probe against packages/utils/src/env.ts, isolated HOME and GJC_CODING_AGENT_DIR, KIMI_CODE_BASE_URL stripped from the parent env:

# Scenario resolved base $env (merged)
K-A hostile project .env https://api.kimi.com (default) https://attacker3.example
K-E whitespace-only inherited default, no blank URL
K-F inherited https://ops.internal/// https://ops.internal
K-G agent-owned .env + hostile project .env agent-dir value attacker value
K-H shell override + hostile project .env shell value shell value

The dropped ?.trim() is safe, and caller precedence is untouched

$env.KIMI_CODE_BASE_URL?.trim() became $credentialEnv("KIMI_CODE_BASE_URL"). Correct rather than lucky: $credentialEnv trims at every branch (env.ts:119-120 for file sources, 164-165 for the live value) and collapses zero-length to undefined. K-E confirms a whitespace-only value yields the default instead of an empty base that would produce a malformed usage URL.

The precedence line itself is byte-identical to dev:

const candidate = baseUrl?.trim() || envBase || DEFAULT_BASE_URL;

Only the resolver behind envBase changed, so an explicit caller-supplied base still wins over the environment. I verified all three arms live: caller value wins (https://caller.example/v1), a whitespace-only caller value correctly falls through to env/default, and trailing-slash normalization at :56 is unaffected. Keeping the caller ahead of env is the right call — that argument is not repo-controlled.

Credential leakage

No logging in the file, and no error path interpolates the base URL or the access token. Clean.

Test hermeticity

Same batch pattern (delete env[KEY], no HOME isolation), and like #3278 it does not flake, for the same structural reason: the negative cases assert .not.toContain("attacker.example") and never the literal default, and the positive cases use an inherited override, which $credentialEnv resolves ahead of homeEnv/homeShellEnv (env.ts:220-229). Verified empirically with a planted ~/.env.

The "keeps an explicit caller base URL ahead of the environment" case is the one that actually protects the precedence line above from a future refactor. Good that it exists.

Residual, latent only: a developer with KIMI_CODE_BASE_URL in ~/.env would still see a leaked value under a stricter assertion. Not reproducible here and not blocking; the batch-wide fix is env.HOME = tempDir() in one pass across these suites.

Changelog and overlap

Correct packages/ai/CHANGELOG.md### Fixed block. Source files disjoint from every other open env-trust PR. #3278 and #3280 share the same changelog anchor line, so I tested it rather than assuming: applying both to dev in sequence auto-merges cleanly. No conflict, no ordering requirement.

CI

Your description noted CI still running; it has since gone terminal green at this exact head — 16 pass, 5 skipped, zero failures, including the dedicated trust suite. Re-checked at review time.

Pre-existing platform gap (not charged to this PR)

Planted GJC_CODING_AGENT_DIR can redirect getAgentDir() (dirs.ts:251) and relocate the trusted agent .env into the repo. Not a regression here — this PR touches neither dirs.ts nor env.ts, and the same escalation defeats the already-merged trustedBrowserEnv on dev today. Separate follow-up.

Merge gating

MERGE_READY is a verdict on this head, not an instruction to merge. Current dev CI is red on unrelated work; hold until green.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Legacy/open batch triage: closing without merge in this lane. Reopen with an updated review request if this should still land.

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Closed as part of legacy/open PR batch triage (external REQUEST_CHANGES close, no-merge lane).

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.

2 participants