CB-542: close the subscription env: bypass, and fix the env: env-carries-anthropic docs
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* <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. */
|
||||
private static boolean isLoopbackBind(String host) {
|
||||
if (host == null || host.isBlank()) {
|
||||
|
||||
@@ -144,7 +144,14 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
}
|
||||
|
||||
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);
|
||||
putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user