Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/added-5876-proxy-gh-auth-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The MITM proxy can now inject GitHub authorization itself, so agents never hold a usable token ([#1861](https://github.com/hivecommons/hive/issues/1861), ported from [#4032](https://github.com/hivecommons/hive/pull/4032)). Opt-in via `HIVE_PROXY_INJECT_GH_AUTH=true`, default OFF: with the flag unset, token delivery and proxy behavior are unchanged. When enabled, the hub diverts each agent's real scoped token to an in-memory registry and writes only a placeholder (`hive-proxy-injected-<agent>`) to the agent's cache file; the proxy strips whatever Authorization an agent sends and injects the UID-identified agent's real token (`token <t>` for REST, `Basic x-access-token:<t>` for git). An unknown agent gets no credential at all — failing loud rather than borrowing another agent's identity — internal hub callers pass through untouched, `/login/...` paths are strip-only, and injection widens MITM interception to all GitHub-family hosts so no tokened request can tunnel past uninspected.
7 changes: 7 additions & 0 deletions src/cmd/hive/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3381,6 +3381,13 @@ func main() {
logger.Error("failed to create github proxy", "error", err)
} else {
githubProxy.SetCanaryScanner(cfg.Ioscan.IsEnabled() && cfg.Ioscan.Canaries, cfg.Ioscan.FailClosed(), ioscan.DefaultCanaries, canaryLeakHandler)
// #1861: the proxy resolves an identified agent to its hub-held scoped
// token via the package-level registry WriteAgentToken feeds (NOT via
// the appAuth instance, which is replaced on key rotation — a closure
// over it would strand the proxy on the stale instance). Wired
// unconditionally: with HIVE_PROXY_INJECT_GH_AUTH unset (the default)
// the proxy never consults the source and the registry stays empty.
githubProxy.SetAgentTokenSource(github.AgentProxyToken)
dashboard.SetProxyViolationsProvider(githubProxy.Violations)
// Lets the dashboard narrow the LiteLLM model dropdown to the set the
// configured key is entitled to, learned by the proxy from a key-info
Expand Down
36 changes: 36 additions & 0 deletions src/pkg/config/proxy_inject.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package config

import (
"os"
"strings"
)

// ProxyInjectGHAuthEnv is the opt-in switch for proxy-side GitHub credential
// injection (#1861): when "true", the hub keeps every agent's tier-scoped
// GitHub App token to itself and the MITM proxy attaches it to each proxied
// GitHub request, while the agent-visible token cache receives a clearly-fake
// placeholder instead (see github.AgentDummyToken). The attack this closes:
// a prompt-injected agent exfiltrating its OWN credential — today the scoped
// token sits in the agent-readable cache / gh credential env, so any agent
// that can be talked into printing it hands out a usable token. With
// injection on, nothing an agent holds authenticates anywhere.
//
// Default OFF: with the flag unset the token delivery and proxy behavior are
// byte-identical to before this flag existed. It must stay opt-in until the
// injection path has soaked on a real spoke; the follow-up that removes
// agent-side token delivery entirely (and closes #1861) flips it.
const ProxyInjectGHAuthEnv = "HIVE_PROXY_INJECT_GH_AUTH"

// proxyInjectGHAuthEnabledValue is the only value that enables injection —
// the same strict "true" match HIVE_PROXY_ADVISORY_OK uses, so a typo fails
// safe (injection stays off and behavior stays exactly as today).
const proxyInjectGHAuthEnabledValue = "true"

// ProxyInjectGHAuth reports whether proxy-side GitHub credential injection
// (#1861) is enabled for this process. Read live from the environment so the
// token-minting path (pkg/github) and any test can gate on it without extra
// plumbing; the proxy itself snapshots it once at construction, mirroring how
// it treats HIVE_PROXY_ADVISORY_OK (a boot-time deployment choice).
func ProxyInjectGHAuth() bool {
return strings.TrimSpace(os.Getenv(ProxyInjectGHAuthEnv)) == proxyInjectGHAuthEnabledValue
}
28 changes: 28 additions & 0 deletions src/pkg/config/proxy_inject_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package config

import "testing"

// TestProxyInjectGHAuth: the #1861 injection flag must be a strict opt-in —
// only the exact value "true" (whitespace-trimmed) enables it, so a typo or a
// truthy-looking value fails safe with injection OFF and fleet behavior
// unchanged.
func TestProxyInjectGHAuth(t *testing.T) {
cases := []struct {
value string
want bool
}{
{"", false},
{"true", true},
{" true \n", true},
{"TRUE", false},
{"1", false},
{"false", false},
{"yes", false},
}
for _, tc := range cases {
t.Setenv(ProxyInjectGHAuthEnv, tc.value)
if got := ProxyInjectGHAuth(); got != tc.want {
t.Errorf("ProxyInjectGHAuth() with %s=%q = %v, want %v", ProxyInjectGHAuthEnv, tc.value, got, tc.want)
}
}
}
27 changes: 25 additions & 2 deletions src/pkg/github/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (

"github.com/golang-jwt/jwt/v5"
gh "github.com/google/go-github/v72/github"

"github.com/hivecommons/hive/pkg/config"
)

const (
Expand Down Expand Up @@ -467,6 +469,23 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a
return fmt.Errorf("minting scoped token for %s: %w", agentName, err)
}

// #1861 (proxy-side credential injection, opt-in): with the flag on, the
// real scoped token never reaches anything the agent can read. It goes into
// the in-memory registry the MITM proxy injects from, and the cache file —
// the single choke point every agent-side consumer reads (gh-wrapper.sh's
// GH_TOKEN, git-credential-hive.sh's password, the manager's GITHUB_TOKEN
// env push for the MCP server) — receives a visibly-fake placeholder
// instead. The attack this closes: a prompt-injected agent exfiltrating its
// own credential; after this divert, everything in the agent's reach is the
// inert `hive-proxy-injected-<agent>` string. Flag off (the default): the
// registry is never populated and the file receives the real token,
// byte-identical to the pre-#1861 behavior.
fileToken := token
if config.ProxyInjectGHAuth() {
storeAgentProxyToken(agentName, token)
fileToken = AgentDummyToken(agentName)
}

if err := os.MkdirAll(agentTokenCacheDir, agentTokenCacheDirPerms); err != nil {
return fmt.Errorf("creating agent token dir: %w", err)
}
Expand All @@ -489,7 +508,7 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a
if err != nil {
return fmt.Errorf("opening agent token cache %s: %w", cachePath, err)
}
if _, err := f.WriteString(token); err != nil {
if _, err := f.WriteString(fileToken); err != nil {
_ = f.Close() // best-effort cleanup; the write error is what's returned
return fmt.Errorf("writing agent token cache %s: %w", cachePath, err)
}
Expand All @@ -507,7 +526,11 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a
"agent", agentName, "uid", agentUID, "path", cachePath)
}

a.logger.Info("per-agent token cached", "agent", agentName, "tier", tier, "uid", agentUID, "pre_created", preCreated)
// proxy_injected says which delivery mode produced this cache write (#1861):
// true = the file holds the inert placeholder and the real token went to the
// proxy's in-memory registry. Never log token material itself — not even a
// prefix — on either path.
a.logger.Info("per-agent token cached", "agent", agentName, "tier", tier, "uid", agentUID, "pre_created", preCreated, "proxy_injected", fileToken != token)
return nil
}

Expand Down
71 changes: 71 additions & 0 deletions src/pkg/github/proxy_token_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package github

import "sync"

// This file is the hub-side half of proxy-side GitHub credential injection
// (#1861). When config.ProxyInjectGHAuth() is on, WriteAgentToken diverts the
// freshly-minted tier-scoped token HERE — an in-memory, hub-process-only
// registry the MITM proxy reads per request — and writes a visibly-fake
// placeholder to the agent-readable cache file instead. The agent's tooling
// (gh-wrapper.sh, git-credential-hive.sh, the manager's GITHUB_TOKEN env
// injection) keeps functioning because each still finds a syntactically-valid
// credential where it expects one, but that credential authenticates nowhere:
// the proxy strips it off every upstream request and substitutes the real
// scoped token from this registry.
//
// The registry is package-level, NOT a field on AppAuth, deliberately: the
// hive re-creates its AppAuth at runtime (key rotation, App re-discovery —
// see the `appAuth = newAppAuth` sites in cmd/hive/main.go), while the proxy
// is wired to its token source exactly once at boot. Hanging the map off an
// AppAuth instance would silently strand the proxy on the pre-rotation
// instance's (empty, stale) map. A package-level store keyed by agent name
// survives AppAuth replacement, exactly like agentTokenCacheDir does for the
// file-based lane.
//
// Lifecycle: entries are overwritten on every mint for the same agent (launch,
// relaunch, and the hourly refreshAgentTokens sweep — the same #3967 cadence
// that keeps the file cache fresh, reused rather than duplicated). Entries for
// removed agents linger until process restart; that is accepted — the
// underlying installation token expires within the hour regardless, so a
// lingering entry decays into a useless string, and it never leaves this
// process.
var (
agentProxyTokensMu sync.RWMutex
agentProxyTokens = make(map[string]string)
)

// agentDummyTokenPrefix is the prefix of the placeholder written to the
// agent-visible token cache under injection mode. It is deliberately NOT a
// GitHub token shape (no ghs_/ghp_ prefix) and self-describing, so that if an
// agent leaks it — into a log, a PR body, a prompt transcript — the leak is
// inert AND immediately diagnosable as the injection placeholder rather than
// mistaken for a live credential.
const agentDummyTokenPrefix = "hive-proxy-injected-"

// AgentDummyToken returns the placeholder credential delivered to an agent in
// place of its real scoped token when proxy-side injection (#1861) is active.
// Including the agent name makes any leak attributable at a glance.
func AgentDummyToken(agentName string) string {
return agentDummyTokenPrefix + agentName
}

// storeAgentProxyToken records an agent's freshly-minted scoped token for the
// proxy to inject. Called only from WriteAgentToken under the injection flag.
func storeAgentProxyToken(agentName, token string) {
agentProxyTokensMu.Lock()
agentProxyTokens[agentName] = token
agentProxyTokensMu.Unlock()
}

// AgentProxyToken resolves an agent name to its hub-held scoped token for
// proxy-side injection (#1861). ok is false when no token has been minted for
// that agent in this process's lifetime — the proxy then injects NOTHING and
// lets the request fail loud at GitHub (401), never falling back to a shared
// or ambient token (that would recreate the pre-#3888 identity hole where an
// unattributed caller could ride another identity's credential).
func AgentProxyToken(agentName string) (string, bool) {
agentProxyTokensMu.RLock()
token, ok := agentProxyTokens[agentName]
agentProxyTokensMu.RUnlock()
return token, ok && token != ""
}
105 changes: 105 additions & 0 deletions src/pkg/github/proxy_token_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package github

// Tests for the hub-side half of proxy credential injection (#1861): under
// the opt-in flag, WriteAgentToken must divert the real scoped token into the
// proxy's in-memory registry and hand the agent-visible cache file only the
// inert placeholder; with the flag off (the fleet default), delivery must be
// byte-identical to before — real token in the file, registry untouched.

import (
"context"
"os"
"strings"
"testing"

"github.com/hivecommons/hive/pkg/config"
)

// resetProxyTokenRegistry empties the package-level registry so tests do not
// observe each other's entries.
func resetProxyTokenRegistry(t *testing.T) {
t.Helper()
agentProxyTokensMu.Lock()
agentProxyTokens = make(map[string]string)
agentProxyTokensMu.Unlock()
t.Cleanup(func() {
agentProxyTokensMu.Lock()
agentProxyTokens = make(map[string]string)
agentProxyTokensMu.Unlock()
})
}

// TestWriteAgentToken_InjectionDivertsRealTokenToRegistry: with the flag on,
// nothing the agent can read may hold the real credential. The attack this
// closes is #1861's core: a prompt-injected agent cat'ing its own token cache
// (or echoing $GH_TOKEN) and posting the value somewhere public — after the
// divert, all it can leak is the self-describing placeholder.
func TestWriteAgentToken_InjectionDivertsRealTokenToRegistry(t *testing.T) {
const realToken = "ghs-real-scoped-token"
const agentName = "guide"
t.Setenv(config.ProxyInjectGHAuthEnv, "true")
resetProxyTokenRegistry(t)

auth, _, closeFn := newFakeAppAuth(t, realToken)
defer closeFn()
useTempCacheDir(t)

if err := auth.WriteAgentToken(context.Background(), agentName, "advisor", 2001); err != nil {
t.Fatalf("WriteAgentToken: %v", err)
}

fileBytes, err := os.ReadFile(AgentTokenCachePath(agentName))
if err != nil {
t.Fatalf("reading agent cache file: %v", err)
}
fileContent := string(fileBytes)

if strings.Contains(fileContent, realToken) {
t.Fatalf("agent-readable cache contains the REAL token under injection mode: %q", fileContent)
}
if want := AgentDummyToken(agentName); fileContent != want {
t.Errorf("cache file = %q, want the placeholder %q", fileContent, want)
}
// The placeholder must be visibly fake: self-describing, attributable to
// the agent, and not shaped like a GitHub token.
if !strings.Contains(fileContent, agentName) || strings.HasPrefix(fileContent, "ghs_") || strings.HasPrefix(fileContent, "ghp_") {
t.Errorf("placeholder %q is not visibly fake/attributable", fileContent)
}

got, ok := AgentProxyToken(agentName)
if !ok || got != realToken {
t.Errorf("AgentProxyToken(%q) = (%q, %v), want the real token for the proxy to inject", agentName, got, ok)
}
}

// TestWriteAgentToken_FlagOffDeliveryUnchanged: with the flag unset, the
// pre-#1861 lane must be untouched — the agent gets the real token in its
// cache file and the proxy registry never learns it. This is the fleet-safety
// guarantee that lets this ship dark and soak.
func TestWriteAgentToken_FlagOffDeliveryUnchanged(t *testing.T) {
const realToken = "ghs-real-scoped-token-flagoff"
const agentName = "scanner"
// Explicitly clear rather than assuming the runner env: t.Setenv also
// restores the prior value afterwards.
t.Setenv(config.ProxyInjectGHAuthEnv, "")
resetProxyTokenRegistry(t)

auth, _, closeFn := newFakeAppAuth(t, realToken)
defer closeFn()
useTempCacheDir(t)

if err := auth.WriteAgentToken(context.Background(), agentName, "advisor", 2001); err != nil {
t.Fatalf("WriteAgentToken: %v", err)
}

fileBytes, err := os.ReadFile(AgentTokenCachePath(agentName))
if err != nil {
t.Fatalf("reading agent cache file: %v", err)
}
if string(fileBytes) != realToken {
t.Errorf("flag-off cache file = %q, want the real token %q (delivery must be unchanged)", fileBytes, realToken)
}
if tok, ok := AgentProxyToken(agentName); ok {
t.Errorf("flag-off registry holds a token (%q) — the registry must only be fed under the flag", tok)
}
}
Loading
Loading