diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index 9d81d6a55..caf6fdd9b 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -96,6 +96,18 @@ jobs: working-directory: . run: bash bin/test_gh_auth_native_no_cat.sh + # #4045: agent CLI backends (observed live: Copilot CLI) re-export their + # own live GitHub credential as GITHUB_TOKEN into every tool shell they + # spawn — after all launch-path scrubbing — letting a wrapper-denied + # agent write to repos via raw curl, outside every gh-wrapper control. + # This EXECUTES shells the way a backend spawns them (token in the spawn + # env) and asserts the BASH_ENV scrub leaves them token-less, with + # positive controls that the probe can see a leak and that the wrapper + # still authenticates from the per-agent cache. + - name: agent shell token scrub (#4045) + working-directory: . + run: bash bin/test_agent_env_scrub.sh + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' diff --git a/bin/README.md b/bin/README.md index df8162580..710a7e7d8 100644 --- a/bin/README.md +++ b/bin/README.md @@ -44,6 +44,7 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy | `gh-wrapper.sh` | Enforcement | `gh` wrapper that injects App tokens and enforces global/per-agent restriction rules from `/etc/hive/restrictions/.json`. | | `hive-open-pr.sh` | Enforcement | Agent-side wrapper for PR creation requests. It writes a request file for the Hive watcher so PRs are opened by the GitHub App bot and pass the same ACMM authorization checks. | | `setup-proxy-iptables.sh` | Enforcement | Installs iptables rules in the container to force GitHub HTTPS traffic through the ACMM proxy even if an agent unsets proxy variables. | +| `agent-env-scrub.sh` | Enforcement | Sourced (never executed) at the start of every shell in an agent's process tree, via `BASH_ENV`/`ENV` from `agent-launch.sh` and an `/etc/bash.bashrc` guard, to unset the live GitHub credentials backend CLIs re-export into agent tool shells (#4045). | | `hive-config.sh` | Config | Shared shell config reader that exposes project, repo, agent, dashboard, health, and policy values parsed from `hive-project.yaml`. | ## Deployment and local operation diff --git a/bin/agent-env-scrub.sh b/bin/agent-env-scrub.sh new file mode 100644 index 000000000..019e3bc4c --- /dev/null +++ b/bin/agent-env-scrub.sh @@ -0,0 +1,48 @@ +# agent-env-scrub.sh — shell-boundary credential scrub for agent tool shells. +# +# SECURITY (#4045): agent CLI backends re-export their own live GitHub +# credentials into the shells they spawn for tool calls. Observed live on a +# Copilot-backed fleet: the CLI authenticates from its persistent credential +# store (/data/copilot-user-token) and sets GITHUB_TOKEN in the environment of +# every tool shell — AFTER all of the hive's launch-path scrubbing (#3931) has +# run. A wrapper-denied agent then fell back to +# curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/... +# and succeeded at a repo write, bypassing every gh-wrapper control (allowlist +# #3854, mode/ACMM gates, merge eligibility, authorship routing, provenance). +# +# This file is SOURCED — never executed — at the start of every shell in the +# agent's process tree, via BASH_ENV/ENV exported by agent-launch.sh (tool +# shells, which are non-interactive) and via the /etc/bash.bashrc guard the +# Dockerfile installs (interactive shells). Because the unset runs at CHILD +# shell startup, it neutralizes tokens however they arrived: inherited from the +# session env, or set explicitly in the spawn env by the backend CLI itself — +# the #4045 mechanism, which no amount of parent-side scrubbing can reach. +# +# What this deliberately does NOT break: +# - The backend CLI process keeps its own auth. The CLI (node/binary) never +# sources this file; COPILOT_GITHUB_TOKEN / GITHUB_TOKEN stay in ITS env +# for Copilot API auth and the built-in GitHub MCP server (the sanctioned, +# hive-mediated write path under github.app_authored_prs). +# - gh-wrapper.sh still authenticates: it sources this at startup (losing +# only inherited token env, which it must never trust anyway — audit H3) +# and then exports GH_TOKEN itself from HIVE_AGENT_TOKEN_CACHE, which is a +# PATH, not a credential, and is deliberately not scrubbed. +# - git-credential-hive.sh reads the per-agent cache file directly; hive-merge +# and hive-open-pr likewise never rely on inherited token env. +# +# Residual (documented, closed elsewhere): a same-uid agent can still read a +# backend CLI's /proc//environ deliberately. That extraction lane — and +# any smuggled credential — is closed at the transport by the MITM proxy's +# Authorization strip/inject (#1861, PR #4032): the proxy, not the agent env, +# decides what credential GitHub ever sees. +# +# POSIX sh compatible (dash-safe): no bashisms, and `unset` of an absent +# variable is not an error. Keep the variable list in sync with +# bin/test_agent_env_scrub.sh, which source-asserts every name below. +unset GITHUB_TOKEN +unset GH_TOKEN +unset GH_ENTERPRISE_TOKEN +unset GITHUB_ENTERPRISE_TOKEN +unset COPILOT_GITHUB_TOKEN +unset GITHUB_COPILOT_TOKEN +unset HIVE_GITHUB_TOKEN diff --git a/bin/agent-launch.sh b/bin/agent-launch.sh index 8ab70f482..94462a502 100755 --- a/bin/agent-launch.sh +++ b/bin/agent-launch.sh @@ -202,6 +202,36 @@ if [[ "$BACKEND" == "copilot" ]]; then fi fi +# SECURITY (#4045): the backend CLI re-exports live GitHub credentials into +# the tool shells it spawns. The Copilot CLI authenticates from its own +# persistent store (or COPILOT_GITHUB_TOKEN above) and sets GITHUB_TOKEN in +# every shell it runs for the agent — after all launch-path scrubbing (#3931) +# has already happened, so unsetting here cannot reach it. Observed live: a +# wrapper-denied agent fell back to raw +# curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/... +# and performed a repo write outside every gh-wrapper control. +# +# BASH_ENV is sourced by every NON-INTERACTIVE bash at startup — i.e. by each +# tool shell the CLI spawns, INCLUDING ones the CLI hands an explicit +# GITHUB_TOKEN in the spawn env — so the scrub runs inside the child, at the +# only boundary that sees the backend's re-export. ENV covers interactive +# POSIX-mode shells the same way. The CLI process itself is not a shell and +# never sources this file, so its own auth (COPILOT_GITHUB_TOKEN, the +# app_authored_prs GITHUB_TOKEN for the built-in GitHub MCP server) is +# untouched: this changes what the AGENT'S SHELLS see, not what the backend +# can do. gh-wrapper.sh and git-credential-hive.sh keep working — both +# authenticate from HIVE_AGENT_TOKEN_CACHE (a path, deliberately not +# scrubbed), never from inherited token env. Interactive shells get the same +# scrub from the /etc/bash.bashrc guard installed by the Dockerfile. +AGENT_ENV_SCRUB="${SCRIPT_DIR}/agent-env-scrub.sh" +[[ -f "$AGENT_ENV_SCRUB" ]] || AGENT_ENV_SCRUB="/usr/local/bin/agent-env-scrub.sh" +if [[ -f "$AGENT_ENV_SCRUB" ]]; then + export BASH_ENV="$AGENT_ENV_SCRUB" + export ENV="$AGENT_ENV_SCRUB" +else + echo "[agent-launch] WARN: agent-env-scrub.sh not found — backend-exported GitHub tokens will be visible in agent tool shells (#4045)" >&2 +fi + # Scrub GitHub token patterns and JWTs from stderr before writing to disk. scrub_tokens() { sed -u -E \ diff --git a/bin/test_agent_env_scrub.sh b/bin/test_agent_env_scrub.sh new file mode 100644 index 000000000..c8861205b --- /dev/null +++ b/bin/test_agent_env_scrub.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Behavioural + source tests for the #4045 fix: backend CLIs re-export live +# GitHub credentials (GITHUB_TOKEN et al.) into the tool shells they spawn for +# agents, bypassing every gh-wrapper control. bin/agent-env-scrub.sh, sourced +# via BASH_ENV at CHILD shell startup, must neutralize the token no matter how +# it arrived — inherited or set explicitly in the spawn env by the CLI (the +# observed mechanism, which parent-side scrubbing like #3931 cannot reach). +# +# Doctrine (audit 6/7): every block-assertion here has a positive control that +# proves the probe can detect the leak, and the sanctioned paths (gh-wrapper +# auth, backend-process auth) are asserted ALIVE — a scrub that broke them +# would get reverted wholesale, which is worse than the vulnerability. +# +# No token material is real anywhere in this file; probes assert presence/ +# absence, never values. +# +# Run: bash bin/test_agent_env_scrub.sh +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRUB="${REPO_ROOT}/bin/agent-env-scrub.sh" +AGENT_LAUNCH="${REPO_ROOT}/bin/agent-launch.sh" +WRAPPER="${REPO_ROOT}/bin/gh-wrapper.sh" +DOCKERFILE="${REPO_ROOT}/src/Dockerfile" + +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { + echo " FAIL: $1" + [ $# -gt 1 ] && echo " $2" + FAIL=$((FAIL + 1)) +} + +# The single source of truth for what the scrub must cover. Adding a var here +# without adding it to agent-env-scrub.sh fails the source assertions below. +SCRUBBED_VARS=( + GITHUB_TOKEN + GH_TOKEN + GH_ENTERPRISE_TOKEN + GITHUB_ENTERPRISE_TOKEN + COPILOT_GITHUB_TOKEN + GITHUB_COPILOT_TOKEN + HIVE_GITHUB_TOKEN +) + +# probe_child +# Spawns a bash -c child the way a backend CLI spawns a tool shell — with the +# given vars set in the SPAWN env (env(1), exactly how the Copilot CLI hands +# GITHUB_TOKEN to its shell tool) — and reports each scrubbed var as +# name=present|absent. +probe_child() { + local bash_env="$1"; shift + local probe='out=""; for v in GITHUB_TOKEN GH_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN COPILOT_GITHUB_TOKEN GITHUB_COPILOT_TOKEN HIVE_GITHUB_TOKEN; do if [ -n "$(eval "printf %s \"\${$v:-}\"")" ]; then out="$out $v=present"; else out="$out $v=absent"; fi; done; printf "%s" "$out"' + if [ -n "$bash_env" ]; then + env "$@" BASH_ENV="$bash_env" bash -c "$probe" + else + # -u BASH_ENV: the runner's own env must not accidentally scrub the + # positive control. + env -u BASH_ENV "$@" bash -c "$probe" + fi +} + +FAKE_ENV=( + GITHUB_TOKEN="fake-github-token-for-test" + GH_TOKEN="fake-gh-token-for-test" + GH_ENTERPRISE_TOKEN="fake-ghe-token-for-test" + GITHUB_ENTERPRISE_TOKEN="fake-github-ent-token-for-test" + COPILOT_GITHUB_TOKEN="fake-copilot-token-for-test" + GITHUB_COPILOT_TOKEN="fake-copilot2-token-for-test" + HIVE_GITHUB_TOKEN="fake-hive-token-for-test" +) + +echo "=== #4045: backend-exported tokens must not survive into agent tool shells ===" + +# ── POSITIVE CONTROL first: the probe must be able to SEE a leak ───────────── +# Without this, a broken probe (or a probe run without the vars) would make +# every absence-assertion below pass vacuously — the exact failure mode audit 6 +# kept finding. +echo "-- positive control: unscrubbed shell shows every token --" +result="$(probe_child "" "${FAKE_ENV[@]}")" +if [[ "$result" != *"=absent"* && "$result" == *"GITHUB_TOKEN=present"* ]]; then + pass "probe detects all injected tokens when no scrub is wired" +else + fail "probe detects all injected tokens when no scrub is wired" "got:$result" +fi + +# ── The #4045 replay: CLI sets tokens in the tool shell's spawn env ────────── +echo "-- scrubbed tool shell: every known token var is absent --" +result="$(probe_child "$SCRUB" "${FAKE_ENV[@]}")" +if [[ "$result" != *"=present"* ]]; then + pass "all ${#SCRUBBED_VARS[@]} token vars absent in a BASH_ENV-scrubbed child" +else + fail "all ${#SCRUBBED_VARS[@]} token vars absent in a BASH_ENV-scrubbed child" "got:$result" +fi + +# The literal incident shape: the shell expansion the bypass relied on must +# come up empty, so the raw-curl fallback sends "Bearer " and 401s. +result="$(env "${FAKE_ENV[@]}" BASH_ENV="$SCRUB" bash -c 'printf "Bearer %s" "${GITHUB_TOKEN:-}"')" +if [ "$result" = "Bearer " ]; then + pass "incident replay: 'Authorization: Bearer \$GITHUB_TOKEN' expands empty" +else + fail "incident replay: 'Authorization: Bearer \$GITHUB_TOKEN' expands empty" "expansion was non-empty" +fi + +# Nested re-export: even a shell whose PARENT was scrubbed gets the token +# re-injected by the CLI layer per spawn — each new bash must re-scrub because +# BASH_ENV stays exported down the tree. +result="$(env "${FAKE_ENV[@]}" BASH_ENV="$SCRUB" bash -c 'GITHUB_TOKEN=fake-reexported-by-cli bash -c "printf %s \"\${GITHUB_TOKEN:+present}\""')" +if [ -z "$result" ]; then + pass "a token re-exported into a NESTED shell is scrubbed again" +else + fail "a token re-exported into a NESTED shell is scrubbed again" "nested shell saw the token" +fi + +# ── Scrub must not strip what the sanctioned paths need ────────────────────── +echo "-- non-credential env survives the scrub --" +result="$(env HIVE_AGENT_TOKEN_CACHE="/var/run/x" HIVE_ACMM_LEVEL=3 HTTPS_PROXY="http://127.0.0.1:3128" BASH_ENV="$SCRUB" bash -c 'printf "%s %s %s" "${HIVE_AGENT_TOKEN_CACHE:+cache}" "${HIVE_ACMM_LEVEL:+acmm}" "${HTTPS_PROXY:+proxy}"')" +if [ "$result" = "cache acmm proxy" ]; then + pass "HIVE_AGENT_TOKEN_CACHE / ACMM / proxy vars pass through untouched" +else + fail "HIVE_AGENT_TOKEN_CACHE / ACMM / proxy vars pass through untouched" "got '$result'" +fi + +# ── SANCTIONED-PATH CONTROL: gh-wrapper still authenticates under the scrub ── +# The wrapper sources the scrub at startup (it is a bash script under the +# agent's BASH_ENV), losing any inherited token env — which it must never trust +# anyway (H3) — then exports GH_TOKEN itself from HIVE_AGENT_TOKEN_CACHE. The +# stub asserts the token it sees is exactly the CACHE one, proving (a) the +# wrapper path is alive and (b) the stale inherited token was replaced. +echo "-- gh-wrapper authenticates from the per-agent cache under the scrub --" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +TOKEN_CACHE="${WORK}/token" +echo "ghs_fake_cache_token_for_test" >"$TOKEN_CACHE" +STUB="${WORK}/gh-stub" +STUB_LOG="${WORK}/stub.log" +# The stub is /bin/sh ON PURPOSE: a bash stub would itself source BASH_ENV at +# startup and scrub the GH_TOKEN the wrapper just exported — the REAL gh is a +# Go binary and sources nothing, so /bin/sh (which reads neither BASH_ENV nor +# non-interactive ENV) models it correctly. +cat >"$STUB" <<'STUBEOF' +#!/bin/sh +if [ "${GH_TOKEN:-}" = "ghs_fake_cache_token_for_test" ]; then + echo "CACHE_TOKEN_OK" >> "${STUB_LOG}" +elif [ -n "${GH_TOKEN:-}" ]; then + echo "WRONG_TOKEN" >> "${STUB_LOG}" +else + echo "UNAUTHENTICATED" >> "${STUB_LOG}" +fi +exit 0 +STUBEOF +chmod +x "$STUB" +: >"$STUB_LOG" +out="$( + env "${FAKE_ENV[@]}" \ + BASH_ENV="$SCRUB" \ + HIVE_GH_WRAPPER_REAL_GH="$STUB" \ + STUB_LOG="$STUB_LOG" \ + HIVE_AGENT="testagent" \ + HIVE_AGENT_ID="testagent" \ + HIVE_AGENT_MODE="ISSUES_PRS_MERGE" \ + HIVE_ACMM_LEVEL=5 \ + HIVE_AGENT_TOKEN_CACHE="$TOKEN_CACHE" \ + HIVE_CONTRIBUTOR_MODE="false" \ + bash "$WRAPPER" pr view 1 --repo owner/repo 2>&1 +)" +rc=$? +stub_saw="$(cat "$STUB_LOG" 2>/dev/null | tail -n1)" +if [ "$rc" -eq 0 ] && [ "$stub_saw" = "CACHE_TOKEN_OK" ]; then + pass "wrapper reached gh with the CACHE token (inherited fakes discarded)" +else + fail "wrapper reached gh with the CACHE token (inherited fakes discarded)" "rc=$rc stub_saw='${stub_saw:-nothing}' out=$(echo "$out" | head -n2 | tr '\n' ' ')" +fi + +# ── The scrub file must be POSIX-sh safe (tool shells may be /bin/sh) ──────── +echo "-- scrub is sh-compatible --" +if sh -c ". '$SCRUB'" 2>/dev/null; then + pass "agent-env-scrub.sh sources cleanly under sh" +else + fail "agent-env-scrub.sh sources cleanly under sh" +fi + +# ── Source-level invariants (drift guards) ─────────────────────────────────── +echo "-- source invariants --" +for v in "${SCRUBBED_VARS[@]}"; do + if grep -qE "^unset[[:space:]]+${v}\$" "$SCRUB"; then + pass "scrub unsets ${v}" + else + fail "scrub unsets ${v}" "add 'unset ${v}' to bin/agent-env-scrub.sh" + fi +done +if grep -qE 'unset[[:space:]].*HIVE_AGENT_TOKEN_CACHE' "$SCRUB"; then + fail "scrub must NOT unset HIVE_AGENT_TOKEN_CACHE (it is a path the wrapper and git credential helper depend on)" +else + pass "scrub leaves HIVE_AGENT_TOKEN_CACHE alone" +fi +if grep -qE '^\s*export BASH_ENV="\$AGENT_ENV_SCRUB"' "$AGENT_LAUNCH" && grep -qE '^\s*export ENV="\$AGENT_ENV_SCRUB"' "$AGENT_LAUNCH"; then + pass "agent-launch.sh wires BASH_ENV and ENV to the scrub" +else + fail "agent-launch.sh wires BASH_ENV and ENV to the scrub" "the #4045 boundary is not installed for tool shells" +fi +if grep -q 'bin/agent-env-scrub.sh /usr/local/bin/agent-env-scrub.sh' "$DOCKERFILE"; then + pass "Dockerfile ships agent-env-scrub.sh" +else + fail "Dockerfile ships agent-env-scrub.sh" "the launcher's fallback path /usr/local/bin/agent-env-scrub.sh would be empty in the image" +fi +if grep -q 'agent-env-scrub.sh' "$DOCKERFILE" && grep -q 'bash.bashrc' "$DOCKERFILE"; then + pass "Dockerfile installs the interactive-shell (/etc/bash.bashrc) arm" +else + fail "Dockerfile installs the interactive-shell (/etc/bash.bashrc) arm" +fi + +echo +echo "=== $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/src/Dockerfile b/src/Dockerfile index 25fea85f1..76f653f89 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -357,6 +357,12 @@ COPY src/deploy/data/ /opt/hive/seed-data/ COPY src/deploy/ttyd-tmux.sh /usr/local/bin/ttyd-tmux.sh COPY src/deploy/hive-panes.sh /usr/local/bin/hive-panes COPY bin/gh-app-token.sh bin/hive-config.sh bin/agent-launch.sh bin/git-credential-hive.sh /usr/local/bin/ +# SECURITY (#4045): sourced (not executed) by every shell in an agent's process +# tree — via BASH_ENV/ENV from agent-launch.sh for non-interactive tool shells, +# and via the /etc/bash.bashrc guard below for interactive ones — to unset the +# GitHub credentials backend CLIs re-export into the shells they spawn. No exec +# bit on purpose: it must only ever be sourced. +COPY bin/agent-env-scrub.sh /usr/local/bin/agent-env-scrub.sh COPY bin/hive-open-pr.sh /usr/local/bin/hive-open-pr COPY bin/hive-merge.sh /usr/local/bin/hive-merge COPY bin/gh-wrapper.sh /usr/local/bin/gh @@ -368,6 +374,13 @@ RUN chmod +x /usr/local/bin/ttyd-tmux.sh /usr/local/bin/hive-panes /usr/local/bi /usr/local/bin/hive-config.sh /usr/local/bin/agent-launch.sh /usr/local/bin/gh \ /usr/local/bin/git-credential-hive.sh /usr/local/bin/hive-open-pr /usr/local/bin/hive-merge +# SECURITY (#4045): interactive-shell arm of the agent credential scrub. +# BASH_ENV (exported by agent-launch.sh) only reaches NON-interactive shells; +# an interactive bash a backend CLI spawns for the agent reads +# /etc/bash.bashrc instead. Gated on the agent-identity env agent-launch.sh +# exports, so an operator's own `kubectl exec` shell is untouched. +RUN printf '\n# hive #4045: scrub backend-exported GitHub credentials from agent shells\n[ -n "${HIVE_AGENT:-}${HIVE_AGENT_ID:-}" ] && . /usr/local/bin/agent-env-scrub.sh || true\n' >> /etc/bash.bashrc + # SECURITY (audit F5-egress / refs #2674, #2678, #3760): the hive process needs # CAP_NET_ADMIN in its EFFECTIVE set so the MITM proxy can stamp SO_MARK on its # own upstream dials and be exempted from the forced-egress iptables REDIRECT