diff --git a/harnesses/contexts/minimax/v1/harness.yaml b/harnesses/contexts/minimax/v1/harness.yaml index 21895c3..0fdf7ba 100644 --- a/harnesses/contexts/minimax/v1/harness.yaml +++ b/harnesses/contexts/minimax/v1/harness.yaml @@ -18,12 +18,12 @@ secrets_required: - name: minimax account_ref: "minimax" mount_path: /run/agent/secrets/minimax - # 0444 — readable by the agent user that runs `claude` (and thus - # apiKeyHelper). The pod has no fsGroup, so the kubelet mounts the - # secret as root:root; mode 0400 would block the legitimate read. - # The file lives in pod-local tmpfs — "world readable" only means - # readable by other processes in this same pod, which we control. - mode: "0444" + # 0400 (root-only) — defense in depth. The agent user CANNOT read this + # mount. init.sh runs as root and `install`s a per-secret copy into the + # agent's home with mode 0600 owned by agent; only that copy is exposed + # to the runtime. Matches the gitea-ssh pattern. If the ESO Secret + # later grows additional keys, they remain inaccessible by default. + mode: "0400" scripts: init: ./init.sh diff --git a/harnesses/contexts/minimax/v1/init.sh b/harnesses/contexts/minimax/v1/init.sh index 5e4d3ac..0a46384 100755 --- a/harnesses/contexts/minimax/v1/init.sh +++ b/harnesses/contexts/minimax/v1/init.sh @@ -1,43 +1,50 @@ #!/bin/bash -# minimax init — write ~/.claude/settings.json with apiKeyHelper. +# minimax init — stage the api_key for the agent user and wire apiKeyHelper. # -# The MiniMax API key is mounted by ESO at /run/agent/secrets/minimax/api_key -# (mode 0400, secrets_required entry in harness.yaml). Claude Code's -# `apiKeyHelper` setting names a command that prints the key on stdout when -# the CLI needs it for an API request — the value never enters this process' -# environment, never appears in /proc//environ of the claude subprocess, -# and is read fresh on each invocation so ESO secret rotations are picked up -# without a process restart. +# Threat model: keep the ESO mount root-only (mode 0400) so the agent user +# cannot directly `cat` /run/agent/secrets/minimax/api_key. init.sh runs as +# root (in uid-wrapper.sh, before the gosu drop) and stages a per-secret +# copy into the agent's home with mode 0600 owned by agent. apiKeyHelper +# points at the COPY. This is the gitea-ssh pattern — only the file the +# harness explicitly grants is reachable by the runtime. # -# Why settings.json (not --settings inline or env vars): -# - ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY in env exposes the secret in -# /proc//environ, log aggregators, ps. The H-SECRET-4 rule disallows -# credential-shaped env vars for that reason. -# - --settings on the runner CLI line couples the runner to the harness -# layout. Per-harness settings.json keeps auth a harness concern. -# - The helper command (`cat `) re-reads on each call, so secret -# rotation propagates without rewriting the config file. +# Rotation handling: this is a one-shot copy at container start. For +# ephemeral container agents (one task = one container) every task starts +# with the latest secret. Long-running sessions don't refresh the copy +# until a future scripts.control_loop hook lands (planning E1-M3). +# +# Auth wire-up: apiKeyHelper output is sent as `Authorization: Bearer +# ` when ANTHROPIC_BASE_URL is non-anthropic.com — exactly what +# api.minimax.io/anthropic requires. The secret value never enters this +# process' env, the claude subprocess' env, or /proc//environ. set -euo pipefail -API_KEY_FILE="/run/agent/secrets/minimax/api_key" +ESO_API_KEY="/run/agent/secrets/minimax/api_key" -if [ ! -r "$API_KEY_FILE" ]; then - echo "ERROR: $API_KEY_FILE not readable. Check ESO ExternalSecret acct-." >&2 +if [ ! -r "$ESO_API_KEY" ]; then + echo "ERROR: $ESO_API_KEY not readable. Check ESO ExternalSecret acct-." >&2 exit 1 fi -# init.sh runs as root in uid-wrapper.sh BEFORE gosu drops privileges to the -# agent user — so $HOME here is /root, not the agent home. Claude Code will -# run as the agent user and read its config from $AGENT_HOME/.claude. Resolve -# the agent home explicitly so settings.json lands where claude looks. +# Resolve the agent user's home (init.sh's $HOME is /root before gosu drop). 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 -# CLAUDE_CONFIG_DIR overrides ~/.claude when set. +# Stage the api_key into a per-secret path owned by agent, mode 0600. +# install(1) handles ownership/mode atomically; the destination is outside +# the read-only ESO mount so we can chmod/chown freely. +STAGED_KEY_DIR="$AGENT_HOME/.claude/secrets" +STAGED_KEY="$STAGED_KEY_DIR/minimax-api-key" +mkdir -p "$STAGED_KEY_DIR" +chown "$AGENT_USER:" "$STAGED_KEY_DIR" 2>/dev/null || true +chmod 0700 "$STAGED_KEY_DIR" +install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_API_KEY" "$STAGED_KEY" + +# Wire apiKeyHelper to the staged copy in the agent's settings.json. CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}" mkdir -p "$CONFIG_DIR" chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true @@ -45,25 +52,25 @@ chmod 0755 "$CONFIG_DIR" SETTINGS_FILE="$CONFIG_DIR/settings.json" -# If a settings.json already exists from another harness layer, merge -# apiKeyHelper into it; otherwise create a minimal file. jq is in the base -# image; fall back to a clean overwrite if it isn't available for any reason. +# Merge into an existing settings.json (from another harness layer) when +# possible; otherwise create a fresh one. if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then TMP=$(mktemp) - jq --arg helper "cat $API_KEY_FILE" \ + jq --arg helper "cat $STAGED_KEY" \ '. + {apiKeyHelper: $helper}' \ "$SETTINGS_FILE" > "$TMP" mv "$TMP" "$SETTINGS_FILE" else cat > "$SETTINGS_FILE" </dev/null || true chmod 0644 "$SETTINGS_FILE" -echo "minimax api_key wired via apiKeyHelper at $SETTINGS_FILE (agent_user=$AGENT_USER)" + +echo "minimax api_key staged at $STAGED_KEY (0600 $AGENT_USER:$AGENT_USER)" +echo "minimax apiKeyHelper wired in $SETTINGS_FILE" diff --git a/harnesses/contexts/z-ai/v1/harness.yaml b/harnesses/contexts/z-ai/v1/harness.yaml index 8d4dca5..478bd60 100644 --- a/harnesses/contexts/z-ai/v1/harness.yaml +++ b/harnesses/contexts/z-ai/v1/harness.yaml @@ -15,10 +15,12 @@ secrets_required: - name: z-ai account_ref: "z-ai" mount_path: /run/agent/secrets/z-ai - # 0444 — readable by the agent user that runs `claude` (and thus - # apiKeyHelper). Pod-local tmpfs; "world readable" only means readable - # by processes in this pod. - mode: "0444" + # 0400 (root-only) — defense in depth. The agent user cannot read this + # mount. init.sh runs as root and stages the auth_token into the agent's + # home with mode 0600. apiKeyHelper points at the staged copy. Matches + # the gitea-ssh pattern; if the ESO Secret later carries additional + # files (e.g. base_url is already there), they remain inaccessible. + mode: "0400" scripts: init: "./init.sh" diff --git a/harnesses/contexts/z-ai/v1/init.sh b/harnesses/contexts/z-ai/v1/init.sh index 46620fe..f2e75bd 100755 --- a/harnesses/contexts/z-ai/v1/init.sh +++ b/harnesses/contexts/z-ai/v1/init.sh @@ -1,41 +1,46 @@ #!/bin/bash -# z-ai init — write ~/.claude/settings.json with apiKeyHelper. +# z-ai init — stage the auth_token for the agent user and wire apiKeyHelper. # -# The Z.ai auth_token is mounted by ESO at -# /run/agent/secrets/z-ai/auth_token (mode 0400, secrets_required entry in -# harness.yaml). Claude Code's `apiKeyHelper` setting names a command that -# prints the key on stdout when the CLI needs it for an API request — the -# value never enters this process' environment, never appears in the claude -# subprocess' /proc//environ, and is read fresh on each invocation so -# ESO secret rotations are picked up without a process restart. +# Threat model: keep the ESO mount root-only (mode 0400) so the agent user +# cannot directly read /run/agent/secrets/z-ai/*. init.sh runs as root and +# stages a single per-secret copy of auth_token into the agent's home with +# mode 0600. Only that staged file is reachable by the runtime; any other +# files in the ESO Secret (e.g. legacy base_url) stay root-only. # -# When ANTHROPIC_BASE_URL points at a non-anthropic.com host (set in -# harness.yaml to https://api.z.ai/api/anthropic), Claude Code routes -# apiKeyHelper output to `Authorization: Bearer `, which is the -# header shape the Z.ai proxy requires. +# Rotation handling: this is a one-shot copy at container start. Ephemeral +# container agents always run init.sh per task — no rotation gap there. +# Long-running sessions need a future scripts.control_loop hook to refresh +# the copy between agent CLI invocations. # -# The K8s Secret may also contain a `base_url` file (legacy from the -# wrapper-script era) — it is intentionally ignored. The base URL is not a -# credential; it lives in harness.yaml. +# Auth wire-up: apiKeyHelper output routes to `Authorization: Bearer ` +# when ANTHROPIC_BASE_URL is non-anthropic.com (set in harness.yaml to +# https://api.z.ai/api/anthropic). The secret value never enters env or any +# process' /proc//environ. set -euo pipefail -API_KEY_FILE="/run/agent/secrets/z-ai/auth_token" +ESO_AUTH_TOKEN="/run/agent/secrets/z-ai/auth_token" -if [ ! -r "$API_KEY_FILE" ]; then - echo "ERROR: $API_KEY_FILE not readable. Check ESO ExternalSecret acct-." >&2 +if [ ! -r "$ESO_AUTH_TOKEN" ]; then + echo "ERROR: $ESO_AUTH_TOKEN not readable. Check ESO ExternalSecret acct-." >&2 exit 1 fi -# init.sh runs as root in uid-wrapper.sh BEFORE gosu drops privileges to the -# agent user. $HOME here is /root, not the agent home — so resolve the agent -# user's home explicitly and write settings.json there. 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 auth_token into a per-secret path owned by agent, mode 0600. +STAGED_KEY_DIR="$AGENT_HOME/.claude/secrets" +STAGED_KEY="$STAGED_KEY_DIR/z-ai-auth-token" +mkdir -p "$STAGED_KEY_DIR" +chown "$AGENT_USER:" "$STAGED_KEY_DIR" 2>/dev/null || true +chmod 0700 "$STAGED_KEY_DIR" +install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_AUTH_TOKEN" "$STAGED_KEY" + +# Wire apiKeyHelper at the staged copy. CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}" mkdir -p "$CONFIG_DIR" chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true @@ -43,24 +48,22 @@ chmod 0755 "$CONFIG_DIR" SETTINGS_FILE="$CONFIG_DIR/settings.json" -# Merge into an existing settings.json (from another harness layer) when -# possible; otherwise create a fresh one. if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then TMP=$(mktemp) - jq --arg helper "cat $API_KEY_FILE" \ + jq --arg helper "cat $STAGED_KEY" \ '. + {apiKeyHelper: $helper}' \ "$SETTINGS_FILE" > "$TMP" mv "$TMP" "$SETTINGS_FILE" else cat > "$SETTINGS_FILE" </dev/null || true chmod 0644 "$SETTINGS_FILE" -echo "z-ai auth_token wired via apiKeyHelper at $SETTINGS_FILE (agent_user=$AGENT_USER)" + +echo "z-ai auth_token staged at $STAGED_KEY (0600 $AGENT_USER:$AGENT_USER)" +echo "z-ai apiKeyHelper wired in $SETTINGS_FILE"