From f8c8805f26214763f84224ddd91ca2698d415b4a Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Thu, 7 May 2026 21:05:33 +1200 Subject: [PATCH] fix(harnesses): stage tokens for agent UID, fix three broken auth paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors agent-runtimes audit + fix. All three M22 Phase 9 wrapper-script auth paths were broken since the cutover: each one ran as the agent UID trying to `cat` a 0400 root-only ESO mount. Same root cause we hit on minimax/z-ai earlier today. Changes: 1. anthropic-cloud-paul-oauth/v1 (NEW in framework) - Mirrors agent-runtimes — was previously only present there. - init.sh stages oauth_token into ~/.claude/.credentials.json (Claude Code's native subscription-OAuth schema). No env, no apiKeyHelper, no wrapper. Restores the equivalent of what harness_init.py used to do for the legacy SOPS path. - The legacy `bin/anthropic-wrapper.sh` was dead code (never wired). 2. gitea-https/v1 - init.sh stages the token to $HOME/.config/git/gitea-https-token (0600 agent:agent) and points the per-host git credential helper at the staged copy. Previously the helper `cat`d the ESO mount path and silently failed at every git invocation. 3. gitea-admin/v1 - init.sh stages the token to $HOME/.config/gitea-admin/token (0600 agent:agent). Wrapper updated to read from the staged copy. - Removes stale `requires: anthropic-cloud/v1` (the only anthropic harness in agent-runtimes is anthropic-cloud-paul-oauth/v1). Pattern matches gitea-ssh / minimax / z-ai: ESO mount stays root-only, init.sh runs as root and `install -m 0600 -o agent -g agent`s a single explicit copy. Per-secret enumeration; future ESO Secret keys remain inaccessible by default. Co-Authored-By: Claude Opus 4.7 --- .../v1/harness.yaml | 18 ++++ .../anthropic-cloud-paul-oauth/v1/init.sh | 82 +++++++++++++++++++ .../gitea-admin/v1/bin/gitea-admin-wrapper.sh | 24 +++--- .../contexts/gitea-admin/v1/harness.yaml | 3 +- harnesses/contexts/gitea-admin/v1/init.sh | 65 ++++++++++++--- harnesses/contexts/gitea-https/v1/init.sh | 68 +++++++++++---- 6 files changed, 221 insertions(+), 39 deletions(-) create mode 100644 harnesses/contexts/anthropic-cloud-paul-oauth/v1/harness.yaml create mode 100755 harnesses/contexts/anthropic-cloud-paul-oauth/v1/init.sh diff --git a/harnesses/contexts/anthropic-cloud-paul-oauth/v1/harness.yaml b/harnesses/contexts/anthropic-cloud-paul-oauth/v1/harness.yaml new file mode 100644 index 0000000..b58f897 --- /dev/null +++ b/harnesses/contexts/anthropic-cloud-paul-oauth/v1/harness.yaml @@ -0,0 +1,18 @@ +kind: context +name: anthropic-cloud-paul-oauth +version: 1 +description: "Anthropic cloud — subscription OAuth token for paul (personal account)" +requires: [] +provides: [claude-code] + +env: + ENFORCE_SUBSCRIPTION_PRICING: "true" + +scripts: + init: "./init.sh" + +secrets_required: + - name: anthropic-cloud-paul-oauth + account_ref: "anthropic-cloud-paul-oauth.user:paul" + mount_path: /run/agent/secrets/anthropic-cloud-paul-oauth + mode: "0400" diff --git a/harnesses/contexts/anthropic-cloud-paul-oauth/v1/init.sh b/harnesses/contexts/anthropic-cloud-paul-oauth/v1/init.sh new file mode 100755 index 0000000..e24ede7 --- /dev/null +++ b/harnesses/contexts/anthropic-cloud-paul-oauth/v1/init.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# anthropic-cloud-paul-oauth init — write Claude Code's native credentials +# file from the ESO-mounted oauth_token. +# +# Threat model: keep the ESO mount root-only (mode 0400) so the agent user +# cannot directly `cat` /run/agent/secrets/anthropic-cloud-paul-oauth/*. +# init.sh runs as root (in uid-wrapper.sh, before the gosu drop) and writes +# a per-secret artefact at $AGENT_HOME/.claude/.credentials.json with mode +# 0600 owned by agent. Claude Code reads that file natively for OAuth-based +# subscription auth — no env vars, no apiKeyHelper, no wrapper script. +# +# Why .credentials.json (not apiKeyHelper): +# - This is the SUBSCRIPTION (Pro/Max) path: ENFORCE_SUBSCRIPTION_PRICING=true +# on this harness causes the runner to strip ANTHROPIC_API_KEY and +# ANTHROPIC_AUTH_TOKEN from the subprocess env so an accidental API key +# can't fall through to per-token billing. Subscription auth flows +# through CLAUDE_CODE_OAUTH_TOKEN — Claude Code's native storage for +# that is .credentials.json with the claudeAiOauth shape. +# - apiKeyHelper would still work, but routes through the api_key path, +# which the runner's subscription-pricing enforcement is specifically +# designed to block. .credentials.json is the canonical OAuth path. +# +# This mirrors what entrypoint/harness_init.py used to do for the legacy +# SOPS-decrypted CLAUDE_CODE_OAUTH_TOKEN path. Phase 9 ESO-cutover removed +# the SOPS files; this init.sh restores the equivalent behaviour for the +# ESO-mounted token. +# +# Rotation handling: per-container init. Ephemeral container agents (one +# task = one container) always pick up the latest mounted oauth_token. +# Long-running sessions need a future scripts.control_loop hook to +# re-stage between agent CLI invocations. + +set -euo pipefail + +ESO_OAUTH="/run/agent/secrets/anthropic-cloud-paul-oauth/oauth_token" + +if [ ! -r "$ESO_OAUTH" ]; then + echo "ERROR: $ESO_OAUTH not readable. Check ESO ExternalSecret acct-." >&2 + exit 1 +fi + +AGENT_USER="${AGENT_USER:-agent}" +AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6) +if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then + AGENT_HOME="/home/$AGENT_USER" +fi + +CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}" +mkdir -p "$CONFIG_DIR" +chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true +chmod 0700 "$CONFIG_DIR" + +# Build the .credentials.json file. The token is piped via stdin so it +# never lands in argv (visible in /proc//cmdline) or env. The schema +# matches what `claude setup-token` produces locally and what the legacy +# SOPS path in harness_init.py wrote. +CRED_FILE="$CONFIG_DIR/.credentials.json" +cat "$ESO_OAUTH" | python3 -c " +import json, sys +token = sys.stdin.read().strip() +print(json.dumps({ + 'claudeAiOauth': { + 'accessToken': token, + 'refreshToken': None, + 'expiresAt': 4102444800000, + 'scopes': [ + 'user:file_upload', + 'user:inference', + 'user:mcp_servers', + 'user:profile', + 'user:sessions:claude_code', + ], + 'subscriptionType': 'max', + 'rateLimitTier': 'default_claude_max_5x', + } +})) +" > "$CRED_FILE" + +chown "$AGENT_USER:" "$CRED_FILE" 2>/dev/null || true +chmod 0600 "$CRED_FILE" + +echo "anthropic-cloud-paul-oauth: oauth_token staged into $CRED_FILE (0600 $AGENT_USER:$AGENT_USER)" diff --git a/harnesses/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh b/harnesses/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh index 23d94eb..537efb5 100755 --- a/harnesses/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh +++ b/harnesses/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh @@ -1,22 +1,25 @@ #!/bin/bash -# gitea-admin-wrapper.sh — reads the ESO-mounted gitea-admin token at exec time -# and prefixes it as a transient env var to the underlying tool. +# gitea-admin-wrapper.sh — reads the agent-staged gitea-admin token at exec +# time and prefixes it as a transient env var to the underlying tool. # -# M22 Phase 9: token mounted at /run/agent/secrets/gitea-admin/token (read-only, -# tmpfs, mode 0400). The token NEVER enters the wrapper's parent shell — only -# the exec'd tool's environment via `exec env VAR=value cmd`. The brief -# presence in /proc//environ of the tool process is the accepted -# floor (per spec/secrets-runtime.md SR-DISP-7 / H-SECRET-4). +# M22 Phase 9 staging pattern: init.sh runs as root and `install`s a 0600 +# agent-owned copy of the ESO-mounted token into $HOME/.config/gitea-admin/ +# token. This wrapper reads the staged copy, not the ESO mount path, so +# the `cat` succeeds under the agent UID. The brief presence of the token +# in /proc//environ of the exec'd tool is the accepted floor +# (per H-SECRET-4 / SR-DISP-7); the wrapper's parent shell never sees it. + set -euo pipefail -TOKEN_FILE="/run/agent/secrets/gitea-admin/token" +TOKEN_FILE="$HOME/.config/gitea-admin/token" # git-credential-helper subcommand: read token and emit git credential format. # Git invokes us as `gitea-admin-wrapper.sh git-credential-helper get` and # expects key=value lines on stdout terminated by a blank line. git_credential_helper() { if [[ ! -r "$TOKEN_FILE" ]]; then - echo "gitea-admin-wrapper: token file not readable at $TOKEN_FILE" >&2 + echo "gitea-admin-wrapper: staged token not readable at $TOKEN_FILE" >&2 + echo "gitea-admin-wrapper: did init.sh run? (it stages the token from the ESO mount)" >&2 exit 1 fi local base_url="${GITEA_BASE_URL:-https://gitea.oreillyit.nz}" @@ -33,7 +36,8 @@ case "${1:-}" in ;; *) if [[ ! -r "$TOKEN_FILE" ]]; then - echo "gitea-admin-wrapper: token file not readable at $TOKEN_FILE" >&2 + echo "gitea-admin-wrapper: staged token not readable at $TOKEN_FILE" >&2 + echo "gitea-admin-wrapper: did init.sh run? (it stages the token from the ESO mount)" >&2 exit 1 fi # `exec env VAR=...` keeps the secret out of the wrapper's parent shell diff --git a/harnesses/contexts/gitea-admin/v1/harness.yaml b/harnesses/contexts/gitea-admin/v1/harness.yaml index 5135b23..cbae8dd 100644 --- a/harnesses/contexts/gitea-admin/v1/harness.yaml +++ b/harnesses/contexts/gitea-admin/v1/harness.yaml @@ -2,8 +2,7 @@ kind: context name: gitea-admin version: 1 description: "Gitea admin: SSH, git identity, API token for skynet org" -requires: - - anthropic-cloud/v1 +requires: [] provides: [gitea-admin] git_identity: diff --git a/harnesses/contexts/gitea-admin/v1/init.sh b/harnesses/contexts/gitea-admin/v1/init.sh index 1e14f2b..a84ed64 100755 --- a/harnesses/contexts/gitea-admin/v1/init.sh +++ b/harnesses/contexts/gitea-admin/v1/init.sh @@ -1,19 +1,60 @@ #!/bin/bash -# init.sh for gitea-admin harness (M22 Phase 9) -# Sets up non-secret env vars and configures git credential helper. -# Credential token is NOT exported here — only read at exec time by the wrapper. +# gitea-admin init — stage the API token for the agent user and configure +# the git credential helper. +# +# Threat model: keep the ESO mount root-only (mode 0400). init.sh runs as +# root and `install`s a per-secret 0600 agent-owned copy at +# $AGENT_HOME/.config/gitea-admin/token. The wrapper at +# bin/gitea-admin-wrapper.sh reads the staged copy (not the ESO mount) at +# exec time. Without staging, every wrapper invocation would fail at the +# `cat` step because the wrapper runs under the agent UID and the ESO file +# is root:root mode 0400. +# +# This was previously broken: the wrapper `cat`d the ESO mount path which +# the agent could not read. Discovered during the M22 Phase 9 audit; the +# wrapper-script approach was never re-validated post-cutover. Stage-and- +# wrapper-points-at-stage matches gitea-ssh / gitea-https / minimax / z-ai. +# +# Rotation handling: per-container init. Long-running sessions need a +# future scripts.control_loop hook to re-stage between operations. set -euo pipefail -# Non-secret configuration -export GITEA_BASE_URL="${GITEA_BASE_URL:-https://gitea.oreillyit.nz}" +ESO_TOKEN="/run/agent/secrets/gitea-admin/token" -# Ensure SSH directory exists with correct permissions -mkdir -p /home/agent/.ssh -chmod 700 /home/agent/.ssh +if [ ! -r "$ESO_TOKEN" ]; then + echo "ERROR: $ESO_TOKEN not readable. Check ESO ExternalSecret acct-." >&2 + exit 1 +fi -# Configure git to use the gitea-admin credential helper wrapper -# The wrapper reads the ESO-mounted token at exec time -git config --global credential.helper "!/opt/harness/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh git-credential-helper" +AGENT_USER="${AGENT_USER:-agent}" +AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6) +if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then + AGENT_HOME="/home/$AGENT_USER" +fi -echo "[gitea-admin] init complete" +# Stage the token into a per-secret path owned by agent, mode 0600. +# Wrapper reads from this path; ESO mount is never accessed at runtime by +# the agent UID. +STAGED_DIR="$AGENT_HOME/.config/gitea-admin" +STAGED_TOKEN="$STAGED_DIR/token" +mkdir -p "$STAGED_DIR" +chown "$AGENT_USER:" "$STAGED_DIR" 2>/dev/null || true +chmod 0700 "$STAGED_DIR" +install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_TOKEN" "$STAGED_TOKEN" + +# Ensure SSH directory exists with correct permissions (legacy SSH path). +mkdir -p "$AGENT_HOME/.ssh" +chown "$AGENT_USER:" "$AGENT_HOME/.ssh" 2>/dev/null || true +chmod 0700 "$AGENT_HOME/.ssh" + +# Non-secret configuration: GITEA_BASE_URL is set in harness env. + +# Configure git to use the gitea-admin credential helper wrapper. Run as +# the agent user so ~/.gitconfig is owned correctly; otherwise root would +# write into /root/.gitconfig and the agent's git wouldn't see the helper. +WRAPPER_PATH="/opt/harness/contexts/gitea-admin/v1/bin/gitea-admin-wrapper.sh" +su - "$AGENT_USER" -c "git config --global credential.helper '!$WRAPPER_PATH git-credential-helper'" + +echo "gitea-admin: token staged at $STAGED_TOKEN (0600 $AGENT_USER:$AGENT_USER)" +echo "gitea-admin: git credential helper configured" diff --git a/harnesses/contexts/gitea-https/v1/init.sh b/harnesses/contexts/gitea-https/v1/init.sh index d999f56..68a3c7a 100755 --- a/harnesses/contexts/gitea-https/v1/init.sh +++ b/harnesses/contexts/gitea-https/v1/init.sh @@ -1,31 +1,69 @@ #!/bin/bash -# Configure git to use git credential helper for gitea.oreillyit.nz via HTTPS. +# gitea-https init — stage the token for the agent user and configure the +# git credential helper to read from the staged copy. # -# The token is read from /run/agent/secrets/gitea-https/token at every git -# invocation (not at init time). This avoids the token appearing in logs. +# Threat model: keep the ESO mount root-only (mode 0400). init.sh runs as +# root and `install`s a per-secret 0600 agent-owned copy at +# $AGENT_HOME/.config/git/gitea-https-token. The git credential helper +# reads from the staged copy at every git invocation; the ESO mount path +# is never accessed by the agent. +# +# This was previously a `cat /run/agent/secrets/gitea-https/token` from +# inside an init.sh-generated helper script, which silently failed because +# the helper runs as agent and the ESO file is root:root mode 0400. The +# stage-and-helper-points-at-stage pattern matches gitea-ssh / minimax / +# z-ai / anthropic-cloud-paul-oauth. +# +# Rotation handling: per-container init. Ephemeral container agents +# always pick up the latest mounted token. Long-running sessions need a +# future scripts.control_loop hook to re-stage between git invocations. set -euo pipefail +ESO_TOKEN="/run/agent/secrets/gitea-https/token" + +if [ ! -r "$ESO_TOKEN" ]; then + echo "ERROR: $ESO_TOKEN not readable. Check ESO ExternalSecret acct-." >&2 + exit 1 +fi + +AGENT_USER="${AGENT_USER:-agent}" +AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6) +if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then + AGENT_HOME="/home/$AGENT_USER" +fi + +# Stage the token into a per-secret path owned by agent, mode 0600. +STAGED_DIR="$AGENT_HOME/.config/git" +STAGED_TOKEN="$STAGED_DIR/gitea-https-token" +mkdir -p "$STAGED_DIR" +chown "$AGENT_USER:" "$STAGED_DIR" 2>/dev/null || true +chmod 0700 "$STAGED_DIR" +install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_TOKEN" "$STAGED_TOKEN" + +# Generate the credential helper. It reads from the STAGED copy, not the +# ESO mount, so it works under the agent's UID. CRED_HELPER="/opt/harness/contexts/gitea-https/v1/git-credential-gitea.sh" - -# Create the credential helper script -cat > "$CRED_HELPER" << 'HELPER_EOF' +cat > "$CRED_HELPER" <