diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d353657..a9d6142 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,6 +7,12 @@ "source": "./plugins/taiga-auto-sync", "description": "Injects IT Infra & Ops Taiga tracking rules on every prompt.", "version": "0.1.0" + }, + { + "name": "openbao-session", + "source": "./plugins/openbao-session", + "description": "Provisions a short-lived OpenBao session token for interactive agents.", + "version": "0.1.0" } ] } diff --git a/marketplace.json b/marketplace.json index 634de75..f905105 100644 --- a/marketplace.json +++ b/marketplace.json @@ -7,6 +7,12 @@ "source": { "source": "local", "path": "./plugins/taiga-auto-sync" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" + }, + { + "name": "openbao-session", + "source": { "source": "local", "path": "./plugins/openbao-session" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Security" } ] } diff --git a/plugins/openbao-session/.claude-plugin/plugin.json b/plugins/openbao-session/.claude-plugin/plugin.json new file mode 100644 index 0000000..ac13c3d --- /dev/null +++ b/plugins/openbao-session/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "openbao-session", + "version": "0.1.0", + "description": "Provisions a short-lived OpenBao session token for interactive agent sessions.", + "author": { "name": "Senkensha" }, + "hooks": "./hooks/claude-codex-hooks.json" +} diff --git a/plugins/openbao-session/.codex-plugin/plugin.json b/plugins/openbao-session/.codex-plugin/plugin.json new file mode 100644 index 0000000..2d02c09 --- /dev/null +++ b/plugins/openbao-session/.codex-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "openbao-session", + "version": "0.1.0+codex.20260901021149", + "description": "Provisions a short-lived OpenBao session token for interactive agent sessions.", + "author": { + "name": "Local developer" + }, + "hooks": "./hooks/claude-codex-hooks.json", + "interface": { + "displayName": "OpenBao Session", + "shortDescription": "Automatic OpenBao session token provisioning.", + "longDescription": "At session start, opens a loopback SSH tunnel to OpenBao and injects a short-lived, scoped session token so the agent can read only the secrets its policy permits.", + "developerName": "Local developer", + "category": "Security", + "capabilities": [ + "Lifecycle hooks" + ], + "defaultPrompt": "Help me use OpenBao Session." + } +} diff --git a/plugins/openbao-session/README.md b/plugins/openbao-session/README.md new file mode 100644 index 0000000..7166512 --- /dev/null +++ b/plugins/openbao-session/README.md @@ -0,0 +1,97 @@ +# openbao-session + +Hook `SessionStart` untuk Claude Code dan Codex yang menyuntikkan **token OpenBao berumur pendek** ke lingkungan sesi agent. Dengan ini agent tidak perlu memegang credential statis — setiap sesi mendapat token baru yang scoped dan kadaluarsa sendiri. + +## Cara kerja + +Saat sesi mulai, `hooks/bao-session.sh`: +1. Memastikan **SSH tunnel loopback** ke listener VPC privat OpenBao hidup (`127.0.0.1:8200 → 10.104.0.12:8200` lewat jumphost `mbu-backup-jumphost`). +2. Mengambil CA cert OpenBao (`/opt/openbao/tls/tls.crt`) dan menyimpannya lokal. +3. Membaca **bootstrap credential** dari secrets store lokal (`manage.py get openbao bootstrap_token`). +4. **Mint token sesi** `claude-session` (TTL `4h`, renewable) memakai bootstrap credential sebagai caller. +5. Menyuntikkan `OPENBAO_TOKEN`, `BAO_ADDR`, `BAO_CACERT`, `BAO_TLS_SERVER_NAME` ke sesi: + +| Host | Mekanisme injeksi | +|---|---| +| **Claude Code** | Menulis `export OPENBAO_TOKEN=...` ke **`$CLAUDE_ENV_FILE`** — file yang di-source Claude Code ke setiap command Bash sesi itu. | +| **Codex / host lain** | Hook Codex **tidak bisa persist env** (proses hook terpisah). Skrip menulis session env file (mode 600) dan memberitahu agent lewat `additionalContext` untuk `source` file itu — **nilai token tidak pernah masuk konteks model**. | + +Token sesi cuma bisa membaca secret yang diizinkan policy `claude-session` (bukan semua). Semua akses tercatat di audit OpenBao. + +## Prasyarat sekali-jalan (platform admin) + +Dengan token admin di jumphost (lihat [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md)): + +```bash +export BAO_ADDR=https://10.104.0.12:8200 +export BAO_CACERT=/opt/openbao/tls/tls.crt +export BAO_TOKEN= # jangan di-paste ke chat + +# 1. Policy bootstrap: bisa membuat token, TIDAK bisa membaca secret +cat > /tmp/openbao-session-creator.hcl <<'EOF' +path "auth/token/create" { + capabilities = ["create", "update", "sudo"] +} +path "auth/token/renew-self" { capabilities = ["update"] } +path "auth/token/revoke-self" { capabilities = ["update"] } +path "auth/token/lookup-self" { capabilities = ["read"] } +EOF +bao policy write openbao-session-creator /tmp/openbao-session-creator.hcl + +# 2. Policy sesi: baca-only untuk secret yang memang di-assign ke agent +# (sempitkan/sesuaikan daftar path mengikuti penugasan Phase 2 migrasi) +cat > /tmp/claude-session.hcl <<'EOF' +# Refine daftar path di sini sesuai owner/consumer assignment. +path "shared/data/*" { capabilities = ["read", "list"] } +path "shared/metadata/*" { capabilities = ["read", "list"] } +EOF +bao policy write claude-session /tmp/claude-session.hcl + +# 3. Issue bootstrap token (TTL panjang, renewable) dan simpan NILAINYA ke secrets store LOKAL: +bao token create -policy=openbao-session-creator -ttl=720h -renewable \ + -display-name=openbao-session-bootstrap -format=json +# → ambil auth.client_token, lalu di Mac Anda: +python3 ~/.config/devops-secrets/manage.py set openbao bootstrap_token --description "OpenBao session bootstrap (creator)" +``` + +> Bootstrap credential ini **kuat** (bisa mint token policy apa pun) tapi **tidak bisa membaca nilai secret**. +> Karena itu simpan hanya di secrets store lokal (`~/.config/devops-secrets`, mode 700/600), jangan di vault/repo/chat. +> Upgrade ke AppRole atau OIDC saat identity provider disetujui. + +## Instalasi + +### Claude Code +- Daftarkan marketplace (jika belum): `claude plugin marketplace add infra-plugins https://git.senkensha.space/senkensha/infra-plugins.git` +- Pasang: `claude plugin install openbao-session@infra-plugins` +- Konfirmasi hook aktif: buka sesi baru, lihat status message "Provisioning OpenBao session token...", lalu `echo ${OPENBAO_TOKEN:+SET}`. + +### Codex +- `codex plugin install openbao-session@infra-plugins` (marketplace sudah terdaftar sebagai `infra-plugins`). + +## Konfigurasi (opsional, via env) + +| Variable | Default | Keterangan | +|---|---|---| +| `OPENBAO_SESSION_CONF_DIR` | `~/.config/openbao` | dir cache CA cert + binary bao | +| `OPENBAO_SESSION_LOCAL_PORT` | `8200` | port tunnel lokal | +| `OPENBAO_SESSION_VPC_ADDR` | `10.104.0.12` | alamat VPC listener OpenBao | +| `OPENBAO_SESSION_DROPLET` | `mbu-backup-jumphost` | droplet jumphost | +| `OPENBAO_SESSION_HOST` | *(kosong)* | public IP jumphost eksplisit (default: resolve via doctl) | +| `OPENBAO_SESSION_SSH_KEY` | `~/.ssh/id_ed25519` | key SSH ke jumphost | +| `OPENBAO_SESSION_POLICY` | `claude-session` | policy token sesi | +| `OPENBAO_SESSION_TTL` | `4h` | umur token sesi | +| `OPENBAO_SESSION_BAO_VER` | `2.6.2` | versi binary bao (auto-download jika belum ada) | +| `OPENBAO_SESSION_SECRETS_HELPER` | `~/.config/devops-secrets/manage.py` | helper baca bootstrap | +| `OPENBAO_SESSION_BOOTSTRAP_TOOL` / `_KEY` | `openbao` / `bootstrap_token` | lokasi bootstrap di secrets store | + +## Keamanan + +- Nilai secret tidak pernah ditulis ke repo, chat, atau log; token sesi mengalir lewat `$CLAUDE_ENV_FILE` (Claude) atau session env file mode 600 (Codex), bukan argv dan bukan ke konteks model. +- Token sesi **short-lived** (`4h`, renewable) dan **scoped** ke policy `claude-session` saja. +- Bootstrap credential tidak bisa membaca secret, tapi bisa mint token → rotasi bila dicurigai bocor: buat bootstrap baru, `manage.py set`, revoke yang lama (`bao token revoke `). +- Tunnel hanya bind `127.0.0.1`; jangan pernah expose port publik atau pakai TLS skip-verify. + +## Related + +- [OpenBao Secret Taxonomy and Migration](../../../Areas/DevOps/Credentials/OpenBao%20Secret%20Taxonomy%20and%20Migration.md) +- [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md) diff --git a/plugins/openbao-session/hooks/bao-session.sh b/plugins/openbao-session/hooks/bao-session.sh new file mode 100755 index 0000000..553c1e5 --- /dev/null +++ b/plugins/openbao-session/hooks/bao-session.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# openbao-session — SessionStart hook. +# +# Provisions a short-lived, policy-scoped OpenBao token for this interactive +# agent session. At session start it: +# 1. ensures the loopback SSH tunnel to the OpenBao private VPC listener is up, +# 2. caches the OpenBao TLS trust anchor locally, +# 3. mints a short-lived session token from a bootstrap credential, +# 4. makes it available to the session: +# - Claude Code: writes `export OPENBAO_TOKEN=...` (and BAO_ADDR, BAO_CACERT, +# BAO_TLS_SERVER_NAME) into $CLAUDE_ENV_FILE, which Claude Code sources +# into every subsequent Bash command of the session. +# - Other hosts (e.g. Codex), which cannot persist env from a hook: writes a +# session-scoped env file (mode 600) and points the agent to it via +# hookSpecificOutput.additionalContext — never the token value itself. +# +# Output: a single valid JSON object on stdout. On failure it still emits a +# benign `{"continue": true, "suppressOutput": true}` and exits 0 so the session +# starts cleanly; diagnostics go to stderr. +# +# One-time admin prerequisites and security notes: see README.md in this plugin. + +set -euo pipefail + +log() { echo "openbao-session: $*" >&2; } + +# ---- configuration (env-overridable, defaults match the current production setup) +CONF_DIR="${OPENBAO_SESSION_CONF_DIR:-$HOME/.config/openbao}" +BIN_DIR="$CONF_DIR/bin" +LOCAL_PORT="${OPENBAO_SESSION_LOCAL_PORT:-8200}" +VPC_ADDR="${OPENBAO_SESSION_VPC_ADDR:-10.104.0.12}" +DROPLET="${OPENBAO_SESSION_DROPLET:-mbu-backup-jumphost}" +SSH_KEY="${OPENBAO_SESSION_SSH_KEY:-$HOME/.ssh/id_ed25519}" +HOST="${OPENBAO_SESSION_HOST:-}" # optional explicit public IP +TLS_NAME="${OPENBAO_SESSION_TLS_NAME:-mbu-backup-jumphost}" +BAO_VER="${OPENBAO_SESSION_BAO_VER:-2.6.2}" +SESSION_POLICY="${OPENBAO_SESSION_POLICY:-claude-session}" +SESSION_TTL="${OPENBAO_SESSION_TTL:-4h}" +SECRETS_HELPER="${OPENBAO_SESSION_SECRETS_HELPER:-$HOME/.config/devops-secrets/manage.py}" +BOOTSTRAP_TOOL="${OPENBAO_SESSION_BOOTSTRAP_TOOL:-openbao}" +BOOTSTRAP_KEY="${OPENBAO_SESSION_BOOTSTRAP_KEY:-bootstrap_token}" +CACERT="$CONF_DIR/tls.crt" +BAO_BIN="" +BAO_ADDR_URL="https://127.0.0.1:${LOCAL_PORT}" +SESSION_TOKEN="" + +benign_json() { printf '%s\n' '{"continue": true, "suppressOutput": true}'; } + +ensure_bao() { + if command -v bao >/dev/null 2>&1; then BAO_BIN="$(command -v bao)"; return 0; fi + if [ -x "$BIN_DIR/bao" ]; then BAO_BIN="$BIN_DIR/bao"; return 0; fi + mkdir -p "$BIN_DIR" + local os arch asset url + os="$(uname -s)"; arch="$(uname -m)" + case "$os:$arch" in + Darwin:arm64) asset="openbao_${BAO_VER}_darwin_arm64.tar.gz" ;; + Darwin:x86_64|Darwin:amd64) asset="openbao_${BAO_VER}_darwin_amd64.tar.gz" ;; + Linux:arm64) asset="openbao_${BAO_VER}_linux_arm64.tar.gz" ;; + Linux:x86_64|Linux:amd64) asset="openbao_${BAO_VER}_linux_amd64.tar.gz" ;; + *) log "unsupported platform: $os/$arch"; return 1 ;; + esac + url="https://github.com/openbao/openbao/releases/download/v${BAO_VER}/${asset}" + curl -fsSL -o "$CONF_DIR/bao.tar.gz" "$url" || { log "failed to download $asset"; return 1; } + tar -xzf "$CONF_DIR/bao.tar.gz" -C "$BIN_DIR" bao || { log "failed to extract bao"; return 1; } + chmod +x "$BIN_DIR/bao" + rm -f "$CONF_DIR/bao.tar.gz" + BAO_BIN="$BIN_DIR/bao" +} + +resolve_host() { + [ -n "$HOST" ] && return 0 + command -v doctl >/dev/null 2>&1 || { log "doctl missing and OPENBAO_SESSION_HOST unset"; return 1; } + HOST="$(doctl compute droplet get "$DROPLET" --format PublicIPv4 --no-header 2>/dev/null | tr -d '[:space:]')" + [[ "$HOST" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { log "cannot resolve $DROPLET public IPv4"; return 1; } +} + +ensure_tunnel() { + if nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1; then return 0; fi + [ -r "$SSH_KEY" ] || { log "SSH key not readable: $SSH_KEY"; return 1; } + resolve_host || return 1 + ssh -f -N -i "$SSH_KEY" -o BatchMode=yes -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o StrictHostKeyChecking=accept-new \ + -L "127.0.0.1:${LOCAL_PORT}:${VPC_ADDR}:8200" "root@${HOST}" || return 1 + sleep 1 + nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1 || { log "tunnel did not come up"; return 1; } +} + +ensure_cacert() { + [ -s "$CACERT" ] && return 0 + resolve_host || return 1 + mkdir -p "$CONF_DIR" + scp -q -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ + "root@${HOST}:/opt/openbao/tls/tls.crt" "$CACERT" || { log "failed to fetch CA cert"; return 1; } +} + +read_bootstrap() { + python3 "$SECRETS_HELPER" get "$BOOTSTRAP_TOOL" "$BOOTSTRAP_KEY" 2>/dev/null \ + || { log "bootstrap credential not found: $BOOTSTRAP_TOOL/$BOOTSTRAP_KEY (see README, one-time admin setup)"; return 1; } +} + +mint_session_token() { + local bootstrap json + bootstrap="$(read_bootstrap)" || return 1 + json="$(BAO_ADDR="$BAO_ADDR_URL" BAO_CACERT="$CACERT" BAO_TLS_SERVER_NAME="$TLS_NAME" BAO_TOKEN="$bootstrap" \ + "$BAO_BIN" token create -policy="$SESSION_POLICY" -ttl="$SESSION_TTL" -renewable \ + -display-name="openbao-session-$(hostname)" -format=json)" || { log "token create failed"; return 1; } + SESSION_TOKEN="$(printf '%s' "$json" | jq -r '.auth.client_token // empty')" + [ -n "$SESSION_TOKEN" ] || { log "no client_token in token create response"; return 1; } +} + +session_id_from_stdin() { + local input + input="$(cat 2>/dev/null || true)" + [ -n "$input" ] && command -v jq >/dev/null 2>&1 \ + && printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null +} + +write_env_exports() { + printf 'export OPENBAO_TOKEN=%q\n' "$SESSION_TOKEN" + printf 'export BAO_ADDR=%q\n' "$BAO_ADDR_URL" + printf 'export BAO_CACERT=%q\n' "$CACERT" + printf 'export BAO_TLS_SERVER_NAME=%q\n' "$TLS_NAME" +} + +inject_env() { + if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + # Claude Code: persist exports for every subsequent Bash command this session. + write_env_exports >> "$CLAUDE_ENV_FILE" || { log "failed to write CLAUDE_ENV_FILE"; return 1; } + benign_json + return 0 + fi + # Other hosts (Codex etc.): hooks cannot persist env; write a session-scoped + # env file and point the agent to it. Never put the token value in context. + local sid envfile + sid="$(session_id_from_stdin)" + envfile="${TMPDIR:-/tmp}/openbao-session.env.${sid:-$$}" + write_env_exports > "$envfile" || return 1 + chmod 600 "$envfile" + printf '{"continue":true,"suppressOutput":true,"hookSpecificOutput":{"additionalContext":"OpenBao session token ready. In a Bash tool, load it without printing the value: source %s"}}\n' "$envfile" +} + +run() { + ensure_bao || return 1 + ensure_tunnel || return 1 + ensure_cacert || return 1 + mint_session_token || return 1 + inject_env || return 1 +} + +main() { + local out rc=0 + out="$(run)" || rc=1 + if [ "$rc" -eq 0 ]; then + printf '%s\n' "$out" + else + log "provisioning failed; session continues without an OpenBao session token" + benign_json + fi + exit 0 +} + +main "$@" diff --git a/plugins/openbao-session/hooks/claude-codex-hooks.json b/plugins/openbao-session/hooks/claude-codex-hooks.json new file mode 100644 index 0000000..576726c --- /dev/null +++ b/plugins/openbao-session/hooks/claude-codex-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/bao-session.sh\"", + "timeout": 30, + "statusMessage": "Provisioning OpenBao session token..." + } + ] + } + ] + } +}