fix(ai): resolve the Kimi usage base URL from trusted env only - #3280
fix(ai): resolve the Kimi usage base URL from trusted env only#328010kH wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Legacy/open batch triage: closing without merge in this lane. Reopen with an updated review request if this should still land.
|
Closed as part of legacy/open PR batch triage (external REQUEST_CHANGES close, no-merge lane). |
Companion to #3278 (Kimi OAuth host) — same provider, different file, kept separate so each stays independently reviewable.
Problem
Bun.env === process.env, and the env module folds the caller'scwd/.envinto it. That base becomes the usage URL (buildUsageUrlat:40, used at:231), and the request sends the credential to it:So a repository could plant one line in
.envand 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.envand the agent.envkeep working.Tests
kimi-usage-baseurl-trust.test.tsspawns a probe with a controlled cwd (the env module parsesprojectEnvat load time, so the boundary is only observable across processes): built-in base by default, a planted.envis ignored, an inherited value still applies, a planted.envcannot 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 checkinpackages/ai(whole-package biome + tsc): exit 0. New suite: 5 pass / 0 fail. Kimi-related suites across the package: 232 pass / 0 fail.