From 3db5277ae81466d9526b8a9752f9f1933b246b14 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 18:25:16 +0200 Subject: [PATCH] CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's herdr overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit herdr spawns a pane from its own login-shell process env and layers our map on top, so any key baseEnv never mentions passes straight through — including the admin forge token. baseEnv now puts a non-blank sentinel for GITEA_ACCESS_TOKEN, applied after the profile's own env: so no profile can restore it. One place, every adapter, every profile including future ones. CB-302's GITEA_TOKEN grant (applyGitToken) is untouched. --- .../bridged/member/HerdrPeerLauncher.java | 28 ++++++++++++- .../member/ClaudeCodeLauncherTest.java | 40 +++++++++++++++++++ .../bridged/member/OpenCodeLauncherTest.java | 15 +++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index 39a252e..af0470e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -766,10 +766,28 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } } - /** A fresh mutable env map — the conventional starting point for {@link #buildLaunch}. */ + /** + * CB-592: overlay value that shadows the admin {@code GITEA_ACCESS_TOKEN} a herdr pane + * otherwise inherits from herdr's own login-shell process environment (gitea issue #77). + * herdr spawns a pane from its own process environment and layers our map on top — + * {@link dev.ltms.bridged.herdr.WorkspaceControl#createTab} and {@code #splitPane} send only + * the keys we put in that map, so any key we never mention passes straight through from + * herdr's own shell, admin token included. + * + *

Deliberately a non-blank sentinel, not {@code ""}. Whether an empty-string overlay value + * overrides an inherited variable or is skipped as blank could not be settled by reading this + * codebase — herdr's server-side merge is an external process, not something in this repo. + * A non-blank replacement sidesteps that ambiguity entirely: {@link #baseEnv}'s own {@code + * PATH} seeding already depends on the overlay reliably replacing an inherited value (see its + * javadoc), and that is only demonstrated for a non-blank value, so this reuses the same, + * proven-reliable shape rather than the unverified one. + */ + private static final String BLOCKED_GITEA_ACCESS_TOKEN = + "blocked-by-bridged-cb592-see-gitea-issue-77"; + /** * Seed a worker's environment (CB-511): the daemon's own {@code PATH}, then the profile's - * {@code env:} entries. + * {@code env:} entries, then the CB-592 admin-token shadow. * *

Why this exists: bridged passes herdr an explicit env map, and herdr merges it into * its own process environment. So before this, a worker inherited whatever PATH the @@ -783,6 +801,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * overriding {@code ANTHROPIC_BASE_URL} and slipping past {@link * dev.ltms.bridged.guard.SubscriptionGuard}, which is checked against the profile's * {@code baseUrl} and nothing else. + * + *

The CB-592 shadow is put in last, after the profile's own {@code env:}, so no + * profile — present or future — can restore the admin token by naming it in config. This is + * the one place the shadow is applied: every {@code buildLaunch} in every adapter calls this + * first, so a new profile, and a peer kind not yet written, gets it for free. */ protected Map baseEnv(BridgedConfig.Profile cfg) { Map workerEnv = new LinkedHashMap<>(); @@ -793,6 +816,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { if (cfg != null && cfg.env() != null) { workerEnv.putAll(cfg.env()); } + workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN); return workerEnv; } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java index ca5ccad..9b77a03 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java @@ -676,6 +676,46 @@ class ClaudeCodeLauncherTest { "the guard-checked baseUrl must win over any env: entry, or the boundary is bypassable"); } + // --- CB-592: the admin GITEA_ACCESS_TOKEN never reaches a member ----------------------------- + + /** + * herdr's env map is an overlay onto its own (login-shell) process environment, so a worker + * inherits whatever the daemon's shell carries — including the admin GITEA_ACCESS_TOKEN — for + * every key baseEnv does not explicitly shadow. This pins that the launcher DOES send an + * explicit (non-blank) GITEA_ACCESS_TOKEN to herdr on every spawn, whatever the profile is, so + * a future baseEnv refactor cannot silently drop it and reopen the leak. Asserted against what + * tab.create's params actually carry, not an internal map built in the test (gitea #77). + */ + @Test + void everySpawnShadowsTheAdminGiteaAccessToken() { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, List.of("claude"), null).spawn(); + + String shadowed = startEnv(herdr).get("GITEA_ACCESS_TOKEN"); + assertNotNull(shadowed, "GITEA_ACCESS_TOKEN must be explicitly overlaid, not left unmentioned"); + assertFalse(shadowed.isBlank(), "a blank overlay value's override behaviour is unverified — must be non-blank"); + } + + /** + * No profile — present or future — may restore the admin token by naming it in {@code env:}. + * The shadow is applied after the profile's own env in {@link HerdrPeerLauncher#baseEnv} + * precisely so this can never happen; this test pins that ordering. + */ + @Test + void aProfileEnvEntryCannotRestoreTheAdminGiteaAccessToken() { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Profile cfg = new BridgedConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", + List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, + null, Map.of("GITEA_ACCESS_TOKEN", "admin-secret-from-profile-config"), null, null); + new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), + _ -> null).spawn(); + + assertNotEquals("admin-secret-from-profile-config", startEnv(herdr).get("GITEA_ACCESS_TOKEN"), + "a profile's own env: must not be able to smuggle the admin token back in"); + } + // ── CB-533: the model is pinned on the command line, not only in the environment ──────────── /** A launcher for a profile identical but for its {@code model:} — the only variable here. */ diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java index 5c1600e..0ee1bfa 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java @@ -208,6 +208,21 @@ class OpenCodeLauncherTest { "a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude"); } + /** + * CB-592: the shadow lives in {@link HerdrPeerLauncher#baseEnv}, shared by every adapter — this + * pins that the opencode path gets it too, not just Claude's. See the matching test in + * {@code ClaudeCodeLauncherTest} for the full rationale (gitea #77). + */ + @Test + void everySpawnShadowsTheAdminGiteaAccessToken(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg(null, null, null)).spawn(); + + String shadowed = startEnv(herdr).get("GITEA_ACCESS_TOKEN"); + assertNotNull(shadowed, "GITEA_ACCESS_TOKEN must be explicitly overlaid, not left unmentioned"); + assertFalse(shadowed.isBlank(), "a blank overlay value's override behaviour is unverified — must be non-blank"); + } + @Test void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr();