#!/bin/bash # bw-vault — Bitwarden/Vaultwarden CLI wrapper with credential blindness # # Security model: Passwords NEVER appear in stdout. Claude Code sees only # metadata (names, usernames, URLs) and exit codes — never actual secrets. # This prevents credentials from flowing through Anthropic's API. set -euo pipefail export PATH="$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin" BW_BIN="$HOME/.local/bin/bw" BW_DATA_DIR="$HOME/.bw-data" CRED_FILE="$HOME/.bw-credentials" # Verify bw binary exists if [[ ! -x "$BW_BIN" ]]; then echo "ERROR: bw CLI not found at $BW_BIN" >&2 echo "Install: brew install bitwarden-cli" >&2 exit 1 fi export BITWARDENCLI_APPDATA_DIR="$BW_DATA_DIR" # Subcommand allowlist SUBCMD="${1:-}" case "$SUBCMD" in search|inject|copy|file|create|edit) ;; *) echo "Usage: bw-vault {search|inject|copy|file|create|edit} [args...]" >&2 echo "" >&2 echo "Commands:" >&2 echo " search Search vault (metadata only)" >&2 echo " inject --as -- Run command with secret as env var" >&2 echo " copy [field] Copy secret to clipboard" >&2 echo " file Write secret to file (0600)" >&2 echo " create Create new vault item" >&2 echo " edit Edit existing vault item" >&2 exit 1 ;; esac # Read credentials using awk (not source — prevents env leakage) if [[ ! -f "$CRED_FILE" ]]; then echo "ERROR: credentials file not found at $CRED_FILE" >&2 echo "Run: bw-vault-setup" >&2 exit 1 fi BW_CLIENTID=$(/usr/bin/awk -F= '/^BW_CLIENTID=/{print $2}' "$CRED_FILE") BW_CLIENTSECRET=$(/usr/bin/awk -F= '/^BW_CLIENTSECRET=/{print $2}' "$CRED_FILE") BW_PASSWORD=$(/usr/bin/awk -F= '/^BW_PASSWORD=/{print $2}' "$CRED_FILE") cleanup() { # Logout on exit (best-effort) "$BW_BIN" logout --nointeraction >/dev/null 2>&1 || true } trap cleanup EXIT # Login with API key (idempotent — "already logged in" is fine) BW_CLIENTID="$BW_CLIENTID" BW_CLIENTSECRET="$BW_CLIENTSECRET" \ "$BW_BIN" login --apikey --nointeraction >/dev/null 2>&1 || true # Unlock vault — password via stdin SESSION=$(echo "$BW_PASSWORD" | \ "$BW_BIN" unlock --passwordfile /dev/stdin --raw 2>/dev/null) # Clear credentials from memory ASAP unset BW_CLIENTID BW_CLIENTSECRET BW_PASSWORD if [[ -z "$SESSION" ]]; then echo "ERROR: failed to unlock vault" >&2 echo "Check credentials in $CRED_FILE and server config" >&2 # Debug: show status "$BW_BIN" status 2>/dev/null >&2 || true exit 1 fi # Sync vault data "$BW_BIN" sync --session "$SESSION" >/dev/null 2>&1 || true # Helper to run bw commands with session bw_cmd() { "$BW_BIN" "$@" --session "$SESSION" 2>/dev/null } case "$SUBCMD" in search) shift if [[ $# -eq 0 ]]; then echo "ERROR: search requires a query" >&2 exit 1 fi QUERY="$*" # Return metadata only — strip password, notes, totp, fields bw_cmd list items --search "$QUERY" | \ /usr/bin/python3 -c " import sys, json try: items = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): print('[]') sys.exit(0) for i in items: i.pop('notes', None) i.pop('fields', None) i.pop('passwordHistory', None) if 'login' in i and i['login']: i['login'].pop('password', None) i['login'].pop('totp', None) if 'card' in i and i['card']: i['card'].pop('number', None) i['card'].pop('code', None) if 'identity' in i and i['identity']: i['identity'].pop('ssn', None) json.dump(items, sys.stdout, indent=2) " ;; inject) shift if [[ $# -lt 4 ]]; then echo "ERROR: usage: inject --as -- " >&2 exit 1 fi ITEM_ID="$1"; shift if [[ "$1" != "--as" ]]; then echo "ERROR: expected --as, got '$1'" >&2 exit 1 fi shift VAR_NAME="$1"; shift # Validate var name (alphanumeric + underscore only) if [[ ! "$VAR_NAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then echo "ERROR: invalid env var name '$VAR_NAME'" >&2 exit 1 fi if [[ "$1" != "--" ]]; then echo "ERROR: expected --, got '$1'" >&2 exit 1 fi shift if [[ $# -eq 0 ]]; then echo "ERROR: no command specified after --" >&2 exit 1 fi SECRET=$(bw_cmd get password "$ITEM_ID") if [[ -z "$SECRET" ]]; then echo "ERROR: failed to retrieve password for '$ITEM_ID'" >&2 exit 1 fi # Run the target command with the secret as an env var export "$VAR_NAME=$SECRET" unset SECRET "$@" ;; copy) shift if [[ $# -eq 0 ]]; then echo "ERROR: copy requires an item id/name" >&2 exit 1 fi ITEM_ID="$1" FIELD="${2:-password}" bw_cmd get "$FIELD" "$ITEM_ID" | /usr/bin/pbcopy echo "Copied $FIELD to clipboard" ;; file) shift if [[ $# -lt 2 ]]; then echo "ERROR: usage: file " >&2 exit 1 fi ITEM_ID="$1" OUTPATH="$2" umask 077 bw_cmd get password "$ITEM_ID" > "$OUTPATH" chmod 600 "$OUTPATH" echo "Written to $OUTPATH (mode 0600)" ;; create) shift echo "Interactive item creation:" read -rp "Item name: " ITEM_NAME read -rp "Username: " ITEM_USER read -rp "URL (optional): " ITEM_URL read -rp "Generate password? [Y/n]: " GEN_PASS if [[ "${GEN_PASS,,}" != "n" ]]; then ITEM_PASS=$(bw_cmd generate -ulns --length 32) else read -rsp "Password: " ITEM_PASS echo fi # Build the item JSON ITEM_JSON=$(/usr/bin/python3 -c " import json, sys item = { 'organizationId': None, 'folderId': None, 'type': 1, 'name': sys.argv[1], 'notes': None, 'favorite': False, 'login': { 'uris': [{'match': None, 'uri': sys.argv[3]}] if sys.argv[3] else [], 'username': sys.argv[2], 'password': sys.argv[4], 'totp': None }, 'fields': [], 'reprompt': 0 } print(json.dumps(item)) " "$ITEM_NAME" "$ITEM_USER" "$ITEM_URL" "$ITEM_PASS") unset ITEM_PASS RESULT=$(echo "$ITEM_JSON" | bw_cmd encode | bw_cmd create item) CREATED_ID=$(echo "$RESULT" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('id','unknown'))" 2>/dev/null || echo "unknown") echo "Created item: $CREATED_ID (use 'bw-vault copy $CREATED_ID' to retrieve password)" ;; edit) shift if [[ $# -eq 0 ]]; then echo "ERROR: edit requires an item id/name" >&2 exit 1 fi ITEM_ID="$1" # Get current item CURRENT=$(bw_cmd get item "$ITEM_ID") if [[ -z "$CURRENT" ]]; then echo "ERROR: item '$ITEM_ID' not found" >&2 exit 1 fi CURRENT_NAME=$(echo "$CURRENT" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))") CURRENT_USER=$(echo "$CURRENT" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('login',{}).get('username','') if d.get('login') else '')") echo "Editing: $CURRENT_NAME (user: $CURRENT_USER)" read -rp "New username (enter to keep): " NEW_USER read -rp "New password? [y/N]: " CHANGE_PASS if [[ "${CHANGE_PASS,,}" == "y" ]]; then read -rp "Generate password? [Y/n]: " GEN_PASS if [[ "${GEN_PASS,,}" != "n" ]]; then NEW_PASS=$(bw_cmd generate -ulns --length 32) else read -rsp "New password: " NEW_PASS echo fi UPDATED=$(echo "$CURRENT" | /usr/bin/python3 -c " import sys, json item = json.load(sys.stdin) new_user = sys.argv[1] if sys.argv[1] else None new_pass = sys.argv[2] if sys.argv[2] else None if new_user and item.get('login'): item['login']['username'] = new_user if new_pass and item.get('login'): item['login']['password'] = new_pass print(json.dumps(item)) " "$NEW_USER" "$NEW_PASS") unset NEW_PASS else UPDATED=$(echo "$CURRENT" | /usr/bin/python3 -c " import sys, json item = json.load(sys.stdin) new_user = sys.argv[1] if sys.argv[1] else None if new_user and item.get('login'): item['login']['username'] = new_user print(json.dumps(item)) " "$NEW_USER") fi ITEM_REAL_ID=$(echo "$CURRENT" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") echo "$UPDATED" | bw_cmd encode | bw_cmd edit item "$ITEM_REAL_ID" >/dev/null echo "Updated item: $ITEM_REAL_ID" ;; esac