Skip to content

fix(coding-agent): resolve the Exa API key from trusted env - #3287

Merged
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/exa-api-key-trust
Jul 28, 2026
Merged

fix(coding-agent): resolve the Exa API key from trusted env#3287
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/exa-api-key-trust

Conversation

@10kH

@10kH 10kH commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Same trust-boundary class as #3272 / #3283, applied to the Exa MCP client.

Problem

// exa/mcp-client.ts:16
export function findApiKey(): string | null {
  return $env.EXA_API_KEY;
}

Bun.env === process.env, and the env module folds the caller's cwd/.env into it, so a repository could supply this key.

Honest severity

The Exa endpoints are hard-coded (https://mcp.exa.ai/mcp, https://websetsmcp.exa.ai/mcp), so this is not a redirect and the user's own key cannot be exfiltrated. What it does allow is credential supply: a repository can hand the agent an account it controls, and the key travels in the request URL, so every search the agent runs through Exa lands in that account's history. That is worth closing, and it is the rule the codebase already states for provider credentials.

Measured with a project .env as the only input (HOME and agent dir neutral):

planted .env    -> {"apiKey":null}
inherited shell -> {"apiKey":"operator-key"}

Fix

Resolve through $credentialEnv. The launching shell, ~/.env, the GJC config .env and the agent .env keep working.

One incidental correctness fix: the function declared string | null but returned $env.EXA_API_KEY, i.e. undefined, when unset. It now returns null, matching the declared type. Callers already branch on truthiness and the exa suites stay green (746 pass).

Tests

exa-api-key-trust.test.ts spawns a probe with a controlled cwd and neutral HOME / GJC_CODING_AGENT_DIR: no key by default, a planted .env cannot supply one, an inherited key still applies, and a planted .env cannot override it.

Proof-first: reverting the resolver fails the planted case.

Verification

bun run check in packages/coding-agent (whole-package biome + tsc): exit 0. New suite: 4 pass / 0 fail. Exa-named suites: 746 pass / 0 fail.

@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.

Verdict: APPROVE — exact head adf8b3f9e7d5ea137bed163acc3c86b6c6673d07

findApiKey() moves from $env.EXA_API_KEY (merged view including caller's cwd/.env) to $credentialEnv("EXA_API_KEY"). The key authenticates every Exa MCP call and travels in the request URL, so this closes an account-redirection/query-visibility exposure.

Checks performed:

  • Trust-source precedence: consistent with the $credentialEnv boundary validated in #3285/#3286.
  • Type correctness bonus: findApiKey() now returns null (not undefined) when nothing supplies a key, matching its declared string | null return type — a genuine, in-scope bug fix riding along with the trust fix, not scope creep.
  • Overlap/order: shares the same packages/coding-agent/CHANGELOG.md anchor as #3286/#3288 — same trivial additive-conflict note as above, no functional overlap in source files.
  • Tests: hermetic (HOME and GJC_CODING_AGENT_DIR both pointed at temp dirs); exercises default/no-key, project-planted rejection, inherited-honored, and project-cannot-override-inherited.
  • CI: all 20 checks pass on the exact head.
  • Novelty: no other open PR touches exa/mcp-client.ts; not a duplicate.
  • No automated reviewer comments present.

No merge action taken — post-#3281 dev CI 30253189244 is nonterminal and this batch is read-only review only.


[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.

Verdict: APPROVE — exact head d152d86889fee89dec0b6e5162aa2feb7603fb65

Rebase-only refresh of the prior exact-head review at adf8b3f9e7d5ea137bed163acc3c86b6c6673d07 (APPROVE).

Checks performed:

  • Content identity confirmed, not assumed: diff <(git show adf8b3f9 --format= -- packages/coding-agent/src/exa/mcp-client.ts packages/coding-agent/test/exa-api-key-trust.test.ts packages/coding-agent/test/fixtures/exa-api-key-probe.ts) <(git show d152d868 --format= -- packages/coding-agent/src/exa/mcp-client.ts packages/coding-agent/test/exa-api-key-trust.test.ts packages/coding-agent/test/fixtures/exa-api-key-probe.ts) is empty — the PR's own commit is byte-for-byte identical to the version the prior review validated. CHANGELOG.md is intentionally excluded from this file set: its diff context shifted because independently-merged dev commits inserted new entries above the same insertion point, which is expected rebase churn, not a content change to this PR's own line.
  • Rebase mechanics verified: git merge-base confirms this PR carries the same single commit both before and after the rebase; every additional commit between the two heads is an independently-merged dev commit (own-PR-numbered), not new work folded into this PR.
  • Exact-head terminal CI: 15 success / 0 failure / 0 pending / 5 skipped (total 20). No pending/queued checks remain.

Prior technical analysis still applies verbatim to this head.


[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.

Adversarial batch re-review — exact head d152d86889fee89dec0b6e5162aa2feb7603fb65

Verdict: MERGE_READY — held, do not merge (see gating).

Trace

findApiKey() in packages/coding-agent/src/exa/mcp-client.ts moves EXA_API_KEY from $env (merged view, includes caller's cwd/.env) to $credentialEnv("EXA_API_KEY") ?? null.

Severity reasoning independently checked: the key authenticates every Exa MCP call and travels in the request URL, so a repository-supplied key redirects the agent's searches through an attacker-controlled account — making the queries themselves visible to that account. That is a real exfiltration channel, not only a billing swap.

Type correctness — a genuine secondary fix

The declared return type is string | null, but the previous body returned $env.EXA_API_KEY, i.e. string | undefined, silently widening at runtime. The explicit ?? null now matches the signature. Callers such as websets.ts and mcp-client.ts:327 branch on falsiness, so behavior is unchanged for both null and undefined — this is a correctness tightening with no behavioral risk.

Residual reads audited

Remaining EXA_API_KEY occurrences on this head are non-credential-resolution sites and correctly left alone:

  • packages/ai/src/stream.ts:114 — provider→env-name label map, not a read.
  • runtime-mcp/config.ts — parses a key out of user-supplied MCP config/URL, a different trust domain.
  • eval/py/runtime.ts, plugins/doctor.ts, cli/fast-help.ts, settings-schema.ts — passthrough allowlist, doctor display, help text.

No unconverted authenticating read of EXA_API_KEY remains.

Tests / CI

exa-api-key-trust.test.ts follows the batch's hermetic subprocess pattern (controlled cwd, neutralized HOME/agent dir). Exact-head CI green: test:packages/coding-agent/test/exa-api-key-trust.test.ts, check:@gajae-code/coding-agent, native-build, cli-smoke.

Merge gating

dev CI is currently red. Durable non-resident hold; sequencing only, no technical defect.


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

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/exa-api-key-trust branch from d152d86 to 75c63af Compare July 28, 2026 03:04
findApiKey() read $env.EXA_API_KEY, the merged view that includes the caller's
cwd/.env. The key authenticates every Exa MCP call and travels in the request
URL (?exaApiKey=...), so repository content could decide which account the
agent's searches run through, and therefore who can see those queries. The Exa
endpoints themselves are hard-coded, so this is credential supply rather than
redirection — stated plainly rather than overclaimed.

Resolve through $credentialEnv. The function also now returns null instead of
undefined when nothing supplies a key, which matches its declared string | null
return type; callers already branch on truthiness and the exa suites stay green.

Regression: a child process with a controlled cwd and neutral HOME/agent dir
proves a planted .env cannot supply the key, an inherited key still applies, and
a planted .env cannot override it. Reverting the resolver fails the planted case.
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/exa-api-key-trust branch from 75c63af to d948f01 Compare July 28, 2026 03:50
@Yeachan-Heo
Yeachan-Heo merged commit a2b76d8 into Yeachan-Heo:dev Jul 28, 2026
18 of 21 checks passed
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