From 5afe8e14d97a398d796b3c8e056f1c75559ff983 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 13 Aug 2026 15:26:20 +0200 Subject: [PATCH] CB-542: close the subscription env: bypass, and fix the env: env-carries-anthropic docs --- .../main/java/dev/ltms/bridged/Bridged.java | 3 + .../ltms/bridged/config/BridgedConfig.java | 56 +++++++++++++- .../bridged/worker/ClaudeCodeLauncher.java | 9 ++- .../bridged/config/BridgedConfigTest.java | 77 +++++++++++++++++++ .../worker/ClaudeCodeLauncherTest.java | 28 +++++++ 5 files changed, 171 insertions(+), 2 deletions(-) diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 38394b4..267aa99 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -87,6 +87,9 @@ public final class Bridged { // dangerous configuration cannot be reached by ignoring a log line. cfg.validateAuthExposure(); cfg.validateLeadScan(); + // CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would + // reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load. + cfg.validateSubscriptionProfiles(); Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() ? Path.of(cfg.herdrSocket()) diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 7b9b47d..6a3229f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -127,7 +127,9 @@ public record BridgedConfig( * refusal: a claude-code profile with no base_url may not spawn, because * spawning one would bill the subscription. Mutually exclusive with * {@code baseUrl} — setting both is a configuration error (the two state - * opposite intents). + * opposite intents). For the same reason, an {@code env:} entry naming + * {@code ANTHROPIC_BASE_URL} or {@code ANTHROPIC_AUTH_TOKEN} is refused at + * config load (CB-542): on the subscription path no guard would vet it. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, @@ -258,6 +260,20 @@ public record BridgedConfig( return gitTokenEnv != null && !gitTokenEnv.isBlank(); } + /** + * True when this profile's {@code env:} block names a worker-side Anthropic binding variable. + * Those two keys are the adapter's, never the operator's: on a claude-code worker + * {@code ANTHROPIC_BASE_URL} is the endpoint the {@code SubscriptionGuard} vetted, and + * {@code ANTHROPIC_AUTH_TOKEN} is injected from {@code tokenEnv}. An {@code env:} entry for + * either is a bypass vector — it is what the subscription path would otherwise let survive + * unguarded — so it is rejected at config load (see + * {@link BridgedConfig#validateSubscriptionProfiles()}). + */ + public boolean envCarriesAnthropicBinding() { + return env != null + && (env.containsKey("ANTHROPIC_BASE_URL") || env.containsKey("ANTHROPIC_AUTH_TOKEN")); + } + /** True when workers should land in their own tab in the worker space. */ public boolean tabPlacement() { return "tab".equals(placement); @@ -666,6 +682,44 @@ public record BridgedConfig( + "confused."); } + /** + * Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding + * (CB-542). + * + *

Why this must be fatal rather than sanitised: {@code subscription: true} deliberately + * stops the launcher from writing {@code ANTHROPIC_BASE_URL}/{@code ANTHROPIC_AUTH_TOKEN} and + * skips the {@code SubscriptionGuard} for that profile. But the profile's {@code env:} map is + * layered into the worker environment separately, so an {@code ANTHROPIC_BASE_URL} sitting + * there would survive into the worker having passed no guard at all — {@code subscription: true} + * plus an {@code env:} repoint is a contradiction just like {@code subscription: true} plus a + * {@code baseUrl}. The launcher also hard-strips these two keys from the worker env as a + * belt-and-braces measure; this method is the loud, load-time refusal so the operator is told + * about the mistake instead of having it silently cleaned up. + * + * @throws IllegalStateException when any subscription profile's {@code env:} names + * {@code ANTHROPIC_BASE_URL} or {@code ANTHROPIC_AUTH_TOKEN}, + * naming the profile and the offending key(s) + */ + public void validateSubscriptionProfiles() { + List bad = new java.util.ArrayList<>(); + workerProfiles().forEach((name, w) -> { + if (w.isSubscription() && w.envCarriesAnthropicBinding()) { + List keys = w.env().keySet().stream() + .filter(k -> k.equals("ANTHROPIC_BASE_URL") || k.equals("ANTHROPIC_AUTH_TOKEN")) + .sorted() + .toList(); + bad.add("worker profile '" + name + "' carries " + keys + " in env: — " + + "subscription: true forbids ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN there, " + + "because on the subscription no guard vets them (they would repoint the " + + "worker past the SubscriptionGuard). Remove them from env: (or drop " + + "subscription: true)."); + } + }); + if (!bad.isEmpty()) { + throw new IllegalStateException("refusing to start: " + String.join(" ", bad)); + } + } + /** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */ private static boolean isLoopbackBind(String host) { if (host == null || host.isBlank()) { diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java index 1e4e392..5149dcd 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java @@ -144,7 +144,14 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { } Map workerEnv = baseEnv(cfg); - if (!onSubscription) { + if (onSubscription) { + // CB-542 belt-and-braces: on the subscription path no guard vets these two keys, and the + // profile's env: is layered in by baseEnv — so strip any that rode in there. Config load + // already rejects this (loudly, naming the profile); this makes the boundary hold even + // for a profile built in code that never passed through that validation. + workerEnv.remove("ANTHROPIC_BASE_URL"); + workerEnv.remove("ANTHROPIC_AUTH_TOKEN"); + } else { workerEnv.put("ANTHROPIC_BASE_URL", baseUrl); putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv())); } diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index 9972a3f..b5bf45d 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -659,4 +659,81 @@ class BridgedConfigTest { assertFalse(cfg.workerProfiles().get("opted").isSubscription(), "a profile without the key stays off-subscription (the default)"); } + + // ── CB-542: subscription:true must not smuggle an unguarded endpoint via env: ─────────────── + + @Test + void aSubscriptionProfileWithAnthropicBaseUrlInEnvIsRejected(@TempDir Path dir) throws Exception { + Path f = dir.resolve("baseUrl.yaml"); + Files.writeString(f, """ + workers: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + env: + ANTHROPIC_BASE_URL: http://anything-not-on-the-allowlist + """); + BridgedConfig cfg = BridgedConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, + cfg::validateSubscriptionProfiles); + assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile"); + assertTrue(e.getMessage().contains("ANTHROPIC_BASE_URL"), + "the refusal names the offending key: " + e.getMessage()); + } + + @Test + void aSubscriptionProfileWithAnthropicAuthTokenInEnvIsRejected(@TempDir Path dir) throws Exception { + Path f = dir.resolve("authToken.yaml"); + Files.writeString(f, """ + workers: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + env: + ANTHROPIC_AUTH_TOKEN: sk-ant-not-on-any-allowlist + """); + BridgedConfig cfg = BridgedConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, + cfg::validateSubscriptionProfiles); + assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile"); + assertTrue(e.getMessage().contains("ANTHROPIC_AUTH_TOKEN"), + "the refusal names the offending key: " + e.getMessage()); + } + + @Test + void aSubscriptionProfileWithACleanEnvPassesValidation(@TempDir Path dir) throws Exception { + Path f = dir.resolve("clean.yaml"); + Files.writeString(f, """ + workers: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + env: + JAVA_HOME: /opt/jdk + """); + BridgedConfig cfg = BridgedConfig.load(f); + + assertDoesNotThrow(cfg::validateSubscriptionProfiles, + "a subscription profile may carry env: — just not the Anthropic binding keys"); + } + + @Test + void aNonSubscriptionProfileMayCarryAnthropicEnvKeys(@TempDir Path dir) throws Exception { + // The override is only dangerous on the subscription path, where no guard could vet it. A + // plain profile's env: is still overwritten by the launcher's guard-checked value (CB-511). + Path f = dir.resolve("nonsub.yaml"); + Files.writeString(f, """ + workers: + gx10: + baseUrl: http://gx10.gw:8000 + env: + ANTHROPIC_BASE_URL: http://something + """); + BridgedConfig cfg = BridgedConfig.load(f); + + assertDoesNotThrow(cfg::validateSubscriptionProfiles, + "only subscription:true profiles are checked — off-subscription ones keep the baseUrl guard"); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java index ce4cfc8..a6f2d80 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java @@ -615,6 +615,7 @@ class ClaudeCodeLauncherTest { assertFalse(spawnedArgs(herdr).contains("--model")); assertNull(startEnv(herdr).get("ANTHROPIC_MODEL")); + } // --- CB-539: subscription-profile opt-in ---------------------------------------------------- @@ -695,4 +696,31 @@ class ClaudeCodeLauncherTest { assertEquals(0, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(), "nothing was spawned before the allowlist refusal"); } + + @Test + void aSubscriptionProfileHasAnEnvSuppliedAnthropicBindingStripped() { + // CB-542: even a subscription profile whose env: carries ANTHROPIC_BASE_URL (or AUTH_TOKEN) + // must not hand them to the worker — on the subscription path no guard would vet them. Config + // load refuses this loudly; this launcher-side strip is the belt-and-braces that makes the + // invariant hold for a profile built in code that never passed through that validation. + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Worker cfg = new BridgedConfig.Worker( + "sonnet", null, "sonnet", null, "BRIDGED_WORKER_TOKEN", + List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", null, null, null, + null, null, null, + Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com", + "ANTHROPIC_AUTH_TOKEN", "sk-ant-bad", "JAVA_HOME", "/opt/jdk"), + null, null, true); + new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), + _ -> "would-be-token").spawn(); + + Map env = startEnv(herdr); + assertNull(env.get("ANTHROPIC_BASE_URL"), + "the unguarded endpoint must not survive into the worker"); + assertNull(env.get("ANTHROPIC_AUTH_TOKEN"), + "the unguarded token must not survive into the worker"); + assertEquals("/opt/jdk", env.get("JAVA_HOME"), + "only the Anthropic binding keys are stripped; the rest of env: still applies"); + } }