fix(minimax,z-ai): stage api_key as root, restore mount mode 0400
Mirrors agent-runtimes commit 6f20b51. CRS serves these harness files to dispatchers, so this repo must match. Switches from "mount mode 0444 so the agent user can `cat` the ESO file" to the gitea-ssh staging pattern: keep the ESO mount root-only (0400), init.sh as root `install`s a per-secret 0600 agent-owned copy, and apiKeyHelper points at the staged copy. Stronger blast-radius guarantee — if the ESO Secret later grows additional keys, they remain root-only unless the harness explicitly stages them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,12 @@ secrets_required:
|
|||||||
- name: minimax
|
- name: minimax
|
||||||
account_ref: "minimax"
|
account_ref: "minimax"
|
||||||
mount_path: /run/agent/secrets/minimax
|
mount_path: /run/agent/secrets/minimax
|
||||||
# 0444 — readable by the agent user that runs `claude` (and thus
|
# 0400 (root-only) — defense in depth. The agent user CANNOT read this
|
||||||
# apiKeyHelper). The pod has no fsGroup, so the kubelet mounts the
|
# mount. init.sh runs as root and `install`s a per-secret copy into the
|
||||||
# secret as root:root; mode 0400 would block the legitimate read.
|
# agent's home with mode 0600 owned by agent; only that copy is exposed
|
||||||
# The file lives in pod-local tmpfs — "world readable" only means
|
# to the runtime. Matches the gitea-ssh pattern. If the ESO Secret
|
||||||
# readable by other processes in this same pod, which we control.
|
# later grows additional keys, they remain inaccessible by default.
|
||||||
mode: "0444"
|
mode: "0400"
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
init: ./init.sh
|
init: ./init.sh
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# Threat model: keep the ESO mount root-only (mode 0400) so the agent user
|
||||||
# (mode 0400, secrets_required entry in harness.yaml). Claude Code's
|
# cannot directly `cat` /run/agent/secrets/minimax/api_key. init.sh runs as
|
||||||
# `apiKeyHelper` setting names a command that prints the key on stdout when
|
# root (in uid-wrapper.sh, before the gosu drop) and stages a per-secret
|
||||||
# the CLI needs it for an API request — the value never enters this process'
|
# copy into the agent's home with mode 0600 owned by agent. apiKeyHelper
|
||||||
# environment, never appears in /proc/<pid>/environ of the claude subprocess,
|
# points at the COPY. This is the gitea-ssh pattern — only the file the
|
||||||
# and is read fresh on each invocation so ESO secret rotations are picked up
|
# harness explicitly grants is reachable by the runtime.
|
||||||
# without a process restart.
|
|
||||||
#
|
#
|
||||||
# Why settings.json (not --settings inline or env vars):
|
# Rotation handling: this is a one-shot copy at container start. For
|
||||||
# - ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY in env exposes the secret in
|
# ephemeral container agents (one task = one container) every task starts
|
||||||
# /proc/<pid>/environ, log aggregators, ps. The H-SECRET-4 rule disallows
|
# with the latest secret. Long-running sessions don't refresh the copy
|
||||||
# credential-shaped env vars for that reason.
|
# until a future scripts.control_loop hook lands (planning E1-M3).
|
||||||
# - --settings on the runner CLI line couples the runner to the harness
|
#
|
||||||
# layout. Per-harness settings.json keeps auth a harness concern.
|
# Auth wire-up: apiKeyHelper output is sent as `Authorization: Bearer
|
||||||
# - The helper command (`cat <file>`) re-reads on each call, so secret
|
# <value>` when ANTHROPIC_BASE_URL is non-anthropic.com — exactly what
|
||||||
# rotation propagates without rewriting the config file.
|
# api.minimax.io/anthropic requires. The secret value never enters this
|
||||||
|
# process' env, the claude subprocess' env, or /proc/<pid>/environ.
|
||||||
|
|
||||||
set -euo pipefail
|
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
|
if [ ! -r "$ESO_API_KEY" ]; then
|
||||||
echo "ERROR: $API_KEY_FILE not readable. Check ESO ExternalSecret acct-<minimax-id>." >&2
|
echo "ERROR: $ESO_API_KEY not readable. Check ESO ExternalSecret acct-<minimax-id>." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# init.sh runs as root in uid-wrapper.sh BEFORE gosu drops privileges to the
|
# Resolve the agent user's home (init.sh's $HOME is /root before gosu drop).
|
||||||
# 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.
|
|
||||||
AGENT_USER="${AGENT_USER:-agent}"
|
AGENT_USER="${AGENT_USER:-agent}"
|
||||||
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
||||||
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
||||||
AGENT_HOME="/home/$AGENT_USER"
|
AGENT_HOME="/home/$AGENT_USER"
|
||||||
fi
|
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}"
|
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}"
|
||||||
mkdir -p "$CONFIG_DIR"
|
mkdir -p "$CONFIG_DIR"
|
||||||
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
||||||
@@ -45,25 +52,25 @@ chmod 0755 "$CONFIG_DIR"
|
|||||||
|
|
||||||
SETTINGS_FILE="$CONFIG_DIR/settings.json"
|
SETTINGS_FILE="$CONFIG_DIR/settings.json"
|
||||||
|
|
||||||
# If a settings.json already exists from another harness layer, merge
|
# Merge into an existing settings.json (from another harness layer) when
|
||||||
# apiKeyHelper into it; otherwise create a minimal file. jq is in the base
|
# possible; otherwise create a fresh one.
|
||||||
# image; fall back to a clean overwrite if it isn't available for any reason.
|
|
||||||
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
||||||
TMP=$(mktemp)
|
TMP=$(mktemp)
|
||||||
jq --arg helper "cat $API_KEY_FILE" \
|
jq --arg helper "cat $STAGED_KEY" \
|
||||||
'. + {apiKeyHelper: $helper}' \
|
'. + {apiKeyHelper: $helper}' \
|
||||||
"$SETTINGS_FILE" > "$TMP"
|
"$SETTINGS_FILE" > "$TMP"
|
||||||
mv "$TMP" "$SETTINGS_FILE"
|
mv "$TMP" "$SETTINGS_FILE"
|
||||||
else
|
else
|
||||||
cat > "$SETTINGS_FILE" <<EOF
|
cat > "$SETTINGS_FILE" <<EOF
|
||||||
{
|
{
|
||||||
"apiKeyHelper": "cat $API_KEY_FILE"
|
"apiKeyHelper": "cat $STAGED_KEY"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# settings.json holds the helper command (a path), not a credential value.
|
# settings.json holds a command (a path), not a credential value.
|
||||||
# It must be readable by the agent user that runs claude.
|
|
||||||
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
||||||
chmod 0644 "$SETTINGS_FILE"
|
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"
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ secrets_required:
|
|||||||
- name: z-ai
|
- name: z-ai
|
||||||
account_ref: "z-ai"
|
account_ref: "z-ai"
|
||||||
mount_path: /run/agent/secrets/z-ai
|
mount_path: /run/agent/secrets/z-ai
|
||||||
# 0444 — readable by the agent user that runs `claude` (and thus
|
# 0400 (root-only) — defense in depth. The agent user cannot read this
|
||||||
# apiKeyHelper). Pod-local tmpfs; "world readable" only means readable
|
# mount. init.sh runs as root and stages the auth_token into the agent's
|
||||||
# by processes in this pod.
|
# home with mode 0600. apiKeyHelper points at the staged copy. Matches
|
||||||
mode: "0444"
|
# 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:
|
scripts:
|
||||||
init: "./init.sh"
|
init: "./init.sh"
|
||||||
|
|||||||
@@ -1,41 +1,46 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# Threat model: keep the ESO mount root-only (mode 0400) so the agent user
|
||||||
# /run/agent/secrets/z-ai/auth_token (mode 0400, secrets_required entry in
|
# cannot directly read /run/agent/secrets/z-ai/*. init.sh runs as root and
|
||||||
# harness.yaml). Claude Code's `apiKeyHelper` setting names a command that
|
# stages a single per-secret copy of auth_token into the agent's home with
|
||||||
# prints the key on stdout when the CLI needs it for an API request — the
|
# mode 0600. Only that staged file is reachable by the runtime; any other
|
||||||
# value never enters this process' environment, never appears in the claude
|
# files in the ESO Secret (e.g. legacy base_url) stay root-only.
|
||||||
# subprocess' /proc/<pid>/environ, and is read fresh on each invocation so
|
|
||||||
# ESO secret rotations are picked up without a process restart.
|
|
||||||
#
|
#
|
||||||
# When ANTHROPIC_BASE_URL points at a non-anthropic.com host (set in
|
# Rotation handling: this is a one-shot copy at container start. Ephemeral
|
||||||
# harness.yaml to https://api.z.ai/api/anthropic), Claude Code routes
|
# container agents always run init.sh per task — no rotation gap there.
|
||||||
# apiKeyHelper output to `Authorization: Bearer <value>`, which is the
|
# Long-running sessions need a future scripts.control_loop hook to refresh
|
||||||
# header shape the Z.ai proxy requires.
|
# the copy between agent CLI invocations.
|
||||||
#
|
#
|
||||||
# The K8s Secret may also contain a `base_url` file (legacy from the
|
# Auth wire-up: apiKeyHelper output routes to `Authorization: Bearer <value>`
|
||||||
# wrapper-script era) — it is intentionally ignored. The base URL is not a
|
# when ANTHROPIC_BASE_URL is non-anthropic.com (set in harness.yaml to
|
||||||
# credential; it lives in harness.yaml.
|
# https://api.z.ai/api/anthropic). The secret value never enters env or any
|
||||||
|
# process' /proc/<pid>/environ.
|
||||||
|
|
||||||
set -euo pipefail
|
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
|
if [ ! -r "$ESO_AUTH_TOKEN" ]; then
|
||||||
echo "ERROR: $API_KEY_FILE not readable. Check ESO ExternalSecret acct-<z-ai-id>." >&2
|
echo "ERROR: $ESO_AUTH_TOKEN not readable. Check ESO ExternalSecret acct-<z-ai-id>." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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_USER="${AGENT_USER:-agent}"
|
||||||
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
||||||
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
||||||
AGENT_HOME="/home/$AGENT_USER"
|
AGENT_HOME="/home/$AGENT_USER"
|
||||||
fi
|
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}"
|
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}"
|
||||||
mkdir -p "$CONFIG_DIR"
|
mkdir -p "$CONFIG_DIR"
|
||||||
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
||||||
@@ -43,24 +48,22 @@ chmod 0755 "$CONFIG_DIR"
|
|||||||
|
|
||||||
SETTINGS_FILE="$CONFIG_DIR/settings.json"
|
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
|
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
||||||
TMP=$(mktemp)
|
TMP=$(mktemp)
|
||||||
jq --arg helper "cat $API_KEY_FILE" \
|
jq --arg helper "cat $STAGED_KEY" \
|
||||||
'. + {apiKeyHelper: $helper}' \
|
'. + {apiKeyHelper: $helper}' \
|
||||||
"$SETTINGS_FILE" > "$TMP"
|
"$SETTINGS_FILE" > "$TMP"
|
||||||
mv "$TMP" "$SETTINGS_FILE"
|
mv "$TMP" "$SETTINGS_FILE"
|
||||||
else
|
else
|
||||||
cat > "$SETTINGS_FILE" <<EOF
|
cat > "$SETTINGS_FILE" <<EOF
|
||||||
{
|
{
|
||||||
"apiKeyHelper": "cat $API_KEY_FILE"
|
"apiKeyHelper": "cat $STAGED_KEY"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# settings.json holds the helper command (a path), not a credential value.
|
|
||||||
# It must be readable by the agent user.
|
|
||||||
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
||||||
chmod 0644 "$SETTINGS_FILE"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user