CB-542: close the subscription env: bypass, and fix the env: env-carries-anthropic docs
CI / contract (pull_request) Successful in 44s
CI / build (pull_request) Successful in 51s

This commit is contained in:
Dai Ha
2026-08-13 15:26:20 +02:00
parent 73f6b12dd8
commit 5afe8e14d9
5 changed files with 171 additions and 2 deletions
@@ -87,6 +87,9 @@ public final class Bridged {
// dangerous configuration cannot be reached by ignoring a log line. // dangerous configuration cannot be reached by ignoring a log line.
cfg.validateAuthExposure(); cfg.validateAuthExposure();
cfg.validateLeadScan(); 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 socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
? Path.of(cfg.herdrSocket()) ? Path.of(cfg.herdrSocket())
@@ -127,7 +127,9 @@ public record BridgedConfig(
* refusal: a claude-code profile with no base_url may not spawn, because * refusal: a claude-code profile with no base_url may not spawn, because
* spawning one would bill the subscription. Mutually exclusive with * spawning one would bill the subscription. Mutually exclusive with
* {@code baseUrl} — setting both is a configuration error (the two state * {@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) @JsonIgnoreProperties(ignoreUnknown = true)
public record Worker(String profile, String baseUrl, String model, public record Worker(String profile, String baseUrl, String model,
@@ -258,6 +260,20 @@ public record BridgedConfig(
return gitTokenEnv != null && !gitTokenEnv.isBlank(); 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. */ /** True when workers should land in their own tab in the worker space. */
public boolean tabPlacement() { public boolean tabPlacement() {
return "tab".equals(placement); return "tab".equals(placement);
@@ -666,6 +682,44 @@ public record BridgedConfig(
+ "confused."); + "confused.");
} }
/**
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
* (CB-542).
*
* <p>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<String> bad = new java.util.ArrayList<>();
workerProfiles().forEach((name, w) -> {
if (w.isSubscription() && w.envCarriesAnthropicBinding()) {
List<String> 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. */ /** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */
private static boolean isLoopbackBind(String host) { private static boolean isLoopbackBind(String host) {
if (host == null || host.isBlank()) { if (host == null || host.isBlank()) {
@@ -144,7 +144,14 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
} }
Map<String, String> workerEnv = baseEnv(cfg); Map<String, String> 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); workerEnv.put("ANTHROPIC_BASE_URL", baseUrl);
putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv())); putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv()));
} }
@@ -659,4 +659,81 @@ class BridgedConfigTest {
assertFalse(cfg.workerProfiles().get("opted").isSubscription(), assertFalse(cfg.workerProfiles().get("opted").isSubscription(),
"a profile without the key stays off-subscription (the default)"); "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");
}
} }
@@ -615,6 +615,7 @@ class ClaudeCodeLauncherTest {
assertFalse(spawnedArgs(herdr).contains("--model")); assertFalse(spawnedArgs(herdr).contains("--model"));
assertNull(startEnv(herdr).get("ANTHROPIC_MODEL")); assertNull(startEnv(herdr).get("ANTHROPIC_MODEL"));
}
// --- CB-539: subscription-profile opt-in ---------------------------------------------------- // --- 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(), assertEquals(0, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
"nothing was spawned before the allowlist refusal"); "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<String, String> 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");
}
} }