From 79e2fa35d547274bc877a8e51b0d8c59ea302025 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 16 Jul 2026 17:00:04 -0500 Subject: [PATCH] fix(mosaic): close claudex isolation gaps S1 (Bedrock/Vertex bypass) + S2 (fail-open catches) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/mosaic/README.md | 11 +- packages/mosaic/src/commands/claudex.spec.ts | 120 ++++++++++++++++++- packages/mosaic/src/commands/claudex.ts | 63 +++++++--- 3 files changed, 176 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/README.md b/packages/mosaic/README.md index ccbf073..6a1b9c9 100644 --- a/packages/mosaic/README.md +++ b/packages/mosaic/README.md @@ -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. **No token leakage.** claudex never reads the proxy's credential file. Claude -Code is handed only `ANTHROPIC_AUTH_TOKEN=unused`; the entire credential-bearing -env family (`ANTHROPIC_*`, `*_TOKEN`, `*_API_KEY`, `*_SECRET`, …) is stripped -from the composed environment. The proxy holds the real OAuth credential. +Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy; +the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`, +`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).** diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index dadfe09..fab7cdc 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -1,5 +1,5 @@ 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 { join } from 'node:path'; import { @@ -205,6 +205,68 @@ describe('resolveClaudexConfigDir', () => { 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) ────────────────────────────────────────────────────── @@ -290,6 +352,62 @@ describe('buildClaudexEnv — zero token leakage', () => { 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', () => { const env = buildClaudexEnv( { diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index 4e08635..f6f47d6 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -78,7 +78,12 @@ function defaultCanonicalizeIntended(p: string): string { try { const real = realpathSync(existing); 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); if (parent === existing) return abs; // reached root without an existing prefix tail.unshift(existing.slice(parent.length + 1)); @@ -94,8 +99,11 @@ function defaultMkdir(p: string): void { function defaultIsSymlink(p: string): boolean { try { return lstatSync(p).isSymbolicLink(); - } catch { - return false; + } catch (err) { + // 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 - * 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 - * then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`, `_SMALL_FAST_MODEL`, - * `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep can't miss a specific - * var a denylist forgot, while still preserving the arbitrary non-credential env - * the harness/MCP/hooks require (PATH, HOME, XDG, terminal, proxies, …), which a - * strict allowlist would fragilely drop. + * from the composed env so no real Anthropic key, OAuth token, or third-party / + * cloud credential can reach the local proxy or be used by Claude Code to bypass + * it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`, + * `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep + * can't miss a specific var a short denylist forgot, while still preserving the + * arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG, + * 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 = - /^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 { configDir: string; @@ -220,9 +250,10 @@ export interface BuildClaudexEnvOptions { /** * 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 isolated config dir and the proxy routing. Claude Code sees only - * `ANTHROPIC_AUTH_TOKEN=unused`; the proxy holds the real OAuth credential, which + * the base env). Strips the entire credential-bearing family AND force-deletes + * the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir + * 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. */ export function buildClaudexEnv( @@ -235,6 +266,10 @@ export function buildClaudexEnv( 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['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL; env['ANTHROPIC_AUTH_TOKEN'] = 'unused';