fix(mosaic): close claudex isolation gaps S1 (Bedrock/Vertex bypass) + S2 (fail-open catches)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Addresses MS-LEAD secrev REQUEST CHANGES on #806.

S1 (CRITICAL — REQ 2 defeated by provider switches + cloud creds): the env sweep
did not neutralize AWS Bedrock / GCP Vertex routing. CLAUDE_CODE_USE_BEDROCK /
CLAUDE_CODE_USE_VERTEX are routing switches whose mere presence makes Claude Code
talk to the real Anthropic API via the ambient cloud credential chain, bypassing
ANTHROPIC_BASE_URL (the loopback proxy) entirely — and the cloud credentials
(AWS_SECRET_ACCESS_KEY, AWS_BEARER_TOKEN_BEDROCK, GOOGLE_APPLICATION_CREDENTIALS,
…) passed straight through.
- buildClaudexEnv now force-deletes the routing switches by exact name
  (CLAUDEX_FORCED_UNSET_ENV) regardless of value — a name pattern is the wrong
  model for a boolean switch.
- CLAUDEX_CREDENTIAL_ENV_RE extended to the cloud-cred families (^AWS_,
  ^GOOGLE_APPLICATION_CREDENTIALS, ^GOOGLE_CLOUD_, ^GCP_) and the mid-string
  _KEY$ / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY, AWS_SECRET_ACCESS_KEY).
- Doc-comment updated to match reality.

S2 (HIGH — fail-open contradicts documented fail-closed): defaultCanonicalizeIntended
and defaultIsSymlink swallowed ANY fs error. Now they distinguish ENOENT
(genuinely absent → safe to continue) from every other errno (ELOOP, EACCES,
ENOTDIR, …) which rethrows and fails CLOSED.

Tests (TDD red-first, 4 new specs red before the fix): Bedrock/Vertex switches +
AWS/GCP creds swept while the loopback proxy stays the only route; mid-string
_KEY/_SECRET closed; real-FS ELOOP (canonicalize) and ENOTDIR (isSymlink) fail
closed; injected EACCES not swallowed.

New-module coverage 100% stmts/lines, 93.75% branch. Full mosaic suite 1169 green;
typecheck / lint / format:check green. No --no-verify.

Refs #790

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-07-16 17:00:04 -05:00
parent ab8c9a2d4b
commit 79e2fa35d5
3 changed files with 176 additions and 18 deletions

View File

@@ -82,9 +82,14 @@ resolved dir can never be — or live under — the real `~/.claude`. A claudex
session therefore cannot mutate your normal Claude Code config. session therefore cannot mutate your normal Claude Code config.
**No token leakage.** claudex never reads the proxy's credential file. Claude **No token leakage.** claudex never reads the proxy's credential file. Claude
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused`; the entire credential-bearing Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
env family (`ANTHROPIC_*`, `*_TOKEN`, `*_API_KEY`, `*_SECRET`, …) is stripped the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
from the composed environment. The proxy holds the real OAuth credential. `GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
from the composed environment. The Bedrock/Vertex routing switches
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
pair) are force-removed regardless of value — otherwise their mere presence
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
proxy. The proxy holds the real OAuth credential.
**Model tiers (override via env).** **Model tiers (override via env).**

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync } from 'node:fs'; import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os'; import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -205,6 +205,68 @@ describe('resolveClaudexConfigDir', () => {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
}); });
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
// it as "does not exist yet, keep walking up" and return a literal path —
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
try {
const a = join(root, 'a');
const b = join(root, 'b');
symlinkSync(b, a, 'dir'); // a -> b
symlinkSync(a, b, 'dir'); // b -> a (loop)
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
expect(() =>
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
// The post-create symlink check must fail closed, not treat it as safe.
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
try {
const file = join(root, 'afile');
writeFileSync(file, 'x');
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
expect(() =>
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
{
realClaudeDir: join(root, '.claude'),
canonicalize: idCanon,
mkdir: () => {},
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
},
),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
{
realClaudeDir: '/home/agent/.claude',
canonicalize: () => {
throw eacces;
},
mkdir: () => {},
isSymlink: () => false,
},
),
).toThrow(/permission denied/);
});
}); });
// ─── model-tier map (P3) ────────────────────────────────────────────────────── // ─── model-tier map (P3) ──────────────────────────────────────────────────────
@@ -290,6 +352,62 @@ describe('buildClaudexEnv — zero token leakage', () => {
expect(env.PATH).toBe('/usr/bin'); expect(env.PATH).toBe('/usr/bin');
}); });
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
// ambient cloud credential chain — reaching the real Anthropic API and
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
// gone from the composed env regardless of the launching env.
const env = buildClaudexEnv(
{
CLAUDE_CODE_USE_BEDROCK: '1',
CLAUDE_CODE_USE_VERTEX: '1',
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
AWS_ACCESS_KEY_ID: 'AKIAREAL',
AWS_SECRET_ACCESS_KEY: 'realsecret',
AWS_SESSION_TOKEN: 'realsession',
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
AWS_REGION: 'us-east-1',
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
PATH: '/usr/bin',
},
{ configDir, models },
);
// Routing switches gone by construction.
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
// Cloud credentials swept — none of the Claude-capable creds survive.
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
expect(env.AWS_REGION).toBeUndefined();
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
// The proxy routing is still the only path.
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.PATH).toBe('/usr/bin');
});
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
const env = buildClaudexEnv(
{
STRIPE_SECRET_KEY: 'sk-live-real',
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
HARMLESS: 'kept',
},
{ configDir, models },
);
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
expect(env.HARMLESS).toBe('kept');
});
it('no credential-NAMED key in the composed env carries a real-looking value', () => { it('no credential-NAMED key in the composed env carries a real-looking value', () => {
const env = buildClaudexEnv( const env = buildClaudexEnv(
{ {

View File

@@ -78,7 +78,12 @@ function defaultCanonicalizeIntended(p: string): string {
try { try {
const real = realpathSync(existing); const real = realpathSync(existing);
return tail.length > 0 ? join(real, ...tail) : real; return tail.length > 0 ? join(real, ...tail) : real;
} catch { } catch (err) {
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
// cannot establish the canonical form — fail CLOSED rather than fall back
// to a possibly-wrong literal path.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
const parent = dirname(existing); const parent = dirname(existing);
if (parent === existing) return abs; // reached root without an existing prefix if (parent === existing) return abs; // reached root without an existing prefix
tail.unshift(existing.slice(parent.length + 1)); tail.unshift(existing.slice(parent.length + 1));
@@ -94,8 +99,11 @@ function defaultMkdir(p: string): void {
function defaultIsSymlink(p: string): boolean { function defaultIsSymlink(p: string): boolean {
try { try {
return lstatSync(p).isSymbolicLink(); return lstatSync(p).isSymbolicLink();
} catch { } catch (err) {
return false; // A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
} }
} }
@@ -200,16 +208,38 @@ export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): Clau
/** /**
* Names of env vars considered credential-bearing. The whole family is stripped * Names of env vars considered credential-bearing. The whole family is stripped
* from the composed env so no real Anthropic key, OAuth token, or third-party * from the composed env so no real Anthropic key, OAuth token, or third-party /
* secret can reach the local proxy or be used by Claude Code to bypass it. We * cloud credential can reach the local proxy or be used by Claude Code to bypass
* then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`, `_SMALL_FAST_MODEL`, * it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
* `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep can't miss a specific * `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
* var a denylist forgot, while still preserving the arbitrary non-credential env * can't miss a specific var a short denylist forgot, while still preserving the
* the harness/MCP/hooks require (PATH, HOME, XDG, terminal, proxies, …), which a * arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
* strict allowlist would fragilely drop. * terminal, proxies, …), which a strict allowlist would fragilely drop.
*
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
* credential chain — a Claude-capable credential that must never survive into a
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
*/ */
export const CLAUDEX_CREDENTIAL_ENV_RE = export const CLAUDEX_CREDENTIAL_ENV_RE =
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|_API_?KEY$|_TOKEN$|_SECRET$/i; /^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
/**
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
* value — after the credential sweep. (REQ 2: isolation must hold for any
* launching env, including a Bedrock/Vertex-configured enterprise host.)
*/
export const CLAUDEX_FORCED_UNSET_ENV = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;
export interface BuildClaudexEnvOptions { export interface BuildClaudexEnvOptions {
configDir: string; configDir: string;
@@ -220,9 +250,10 @@ export interface BuildClaudexEnvOptions {
/** /**
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates * Compose the launch env for Claude Code. Returns a FRESH object (never mutates
* the base env). Strips the entire credential-bearing family (REQ 2), then sets * the base env). Strips the entire credential-bearing family AND force-deletes
* the isolated config dir and the proxy routing. Claude Code sees only * the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
* `ANTHROPIC_AUTH_TOKEN=unused`; the proxy holds the real OAuth credential, which * and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
* this module never reads. * this module never reads.
*/ */
export function buildClaudexEnv( export function buildClaudexEnv(
@@ -235,6 +266,10 @@ export function buildClaudexEnv(
env[name] = value; env[name] = value;
} }
// Force-delete routing switches by exact name — their presence (not their
// value) is what would route Claude Code off the proxy to the real API.
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
env['CLAUDE_CONFIG_DIR'] = opts.configDir; env['CLAUDE_CONFIG_DIR'] = opts.configDir;
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL; env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
env['ANTHROPIC_AUTH_TOKEN'] = 'unused'; env['ANTHROPIC_AUTH_TOKEN'] = 'unused';