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
12 changes: 12 additions & 0 deletions .github/workflows/v2-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<agent-id>.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
Expand Down
48 changes: 48 additions & 0 deletions bin/agent-env-scrub.sh
Original file line number Diff line number Diff line change
@@ -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/<pid>/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
30 changes: 30 additions & 0 deletions bin/agent-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
217 changes: 217 additions & 0 deletions bin/test_agent_env_scrub.sh
Original file line number Diff line number Diff line change
@@ -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 <BASH_ENV-value or ""> <extra env assignments...>
# 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
13 changes: 13 additions & 0 deletions src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading