add bw-vault: Vaultwarden CLI wrapper with credential blindness for Claude Code

This commit is contained in:
Viktor Barzin 2026-03-15 15:01:59 +00:00
parent fb3beffe18
commit 70208da97e
4 changed files with 518 additions and 0 deletions

View file

@ -0,0 +1,49 @@
name: vaultwarden
description: Manage passwords in Vaultwarden. Use when needing credentials
for services, databases, APIs, or when storing new secrets.
---
## CRITICAL: Credential Blindness
NEVER use commands that would print passwords to stdout.
Passwords must NEVER appear in tool output sent to Anthropic's API.
## Available commands (each triggers Touch ID)
### Search (safe — returns metadata only)
```bash
bw-vault search <query>
```
Returns: item name, username, URL, id — NO passwords
### Inject password into a command (safe — password never in output)
```bash
bw-vault inject <item-name-or-id> --as <ENV_VAR> -- <command...>
```
Example: `bw-vault inject "prod-db" --as PGPASSWORD -- psql -h db.local -U admin`
### Copy to clipboard (safe — only "Copied" message returned)
```bash
bw-vault copy <item-name-or-id> [field]
```
field defaults to "password", can be "username", "totp", "uri"
### Write to temp file (safe — only file path returned)
```bash
bw-vault file <item-name-or-id> /tmp/secret-XXXX
```
### Create new item (password auto-generated)
```bash
bw-vault create
```
### Edit existing item
```bash
bw-vault edit <item-name-or-id>
```
## NEVER DO
- `bw get password <id>` — would leak to API
- `cat /tmp/secret-XXXX` — would leak file contents to API
- `echo $PGPASSWORD` — would leak env var to API
- Any command that prints a secret value to stdout

View file

@ -0,0 +1,274 @@
#!/bin/bash
# bw-vault-unlock — Privileged Bitwarden CLI wrapper
# Installed to /usr/local/bin/bw-vault-unlock (root:wheel 0755)
# Called via sudo from ~/.local/bin/bw-vault
#
# Security: passwords never printed to stdout. Only metadata, exit codes,
# and "Copied to clipboard" messages are returned.
set -euo pipefail
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
BW_BIN="/opt/homebrew/bin/bw"
BW_HASH_FILE="/var/root/.bw-hash"
# Verify bw binary integrity
if [[ -f "$BW_HASH_FILE" ]]; then
EXPECTED_HASH=$(/usr/bin/head -1 "$BW_HASH_FILE")
ACTUAL_HASH=$(/usr/bin/shasum -a 256 "$BW_BIN" | /usr/bin/awk '{print $1}')
if [[ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]]; then
echo "ERROR: bw binary integrity check failed" >&2
echo "Expected: $EXPECTED_HASH" >&2
echo "Actual: $ACTUAL_HASH" >&2
echo "Run: sudo shasum -a 256 /opt/homebrew/bin/bw | awk '{print \$1}' > /var/root/.bw-hash" >&2
exit 1
fi
fi
export BITWARDENCLI_APPDATA_DIR="/var/root/.bw-data"
# 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 <query> Search vault (metadata only)" >&2
echo " inject <id> --as <VAR> -- <cmd...> Run command with secret as env var" >&2
echo " copy <id> [field] Copy secret to clipboard" >&2
echo " file <id> <path> Write secret to file (0600)" >&2
echo " create Create new vault item" >&2
echo " edit <id> Edit existing vault item" >&2
exit 1
;;
esac
# Read credentials using awk (not source — prevents env leakage)
CRED_FILE="/var/root/.bw-credentials"
if [[ ! -f "$CRED_FILE" ]]; then
echo "ERROR: credentials file not found at $CRED_FILE" >&2
echo "Run the setup script first: sudo 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)
env -i PATH="$PATH" BITWARDENCLI_APPDATA_DIR="$BITWARDENCLI_APPDATA_DIR" \
"$BW_BIN" logout --nointeraction 2>/dev/null || true
}
trap cleanup EXIT
# Login with API key (clean env, idempotent)
env -i PATH="$PATH" BITWARDENCLI_APPDATA_DIR="$BITWARDENCLI_APPDATA_DIR" \
BW_CLIENTID="$BW_CLIENTID" BW_CLIENTSECRET="$BW_CLIENTSECRET" \
"$BW_BIN" login --apikey --nointeraction 2>/dev/null || true
# Unlock vault — password via stdin
SESSION=$(echo "$BW_PASSWORD" | env -i PATH="$PATH" \
BITWARDENCLI_APPDATA_DIR="$BITWARDENCLI_APPDATA_DIR" \
"$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
exit 1
fi
# Helper to run bw commands with session
bw_cmd() {
env -i PATH="$PATH" BITWARDENCLI_APPDATA_DIR="$BITWARDENCLI_APPDATA_DIR" \
"$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:
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 <item-id> --as <VAR> -- <command...>" >&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 <item-id> <output-path>" >&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

View file

@ -0,0 +1,3 @@
#!/bin/bash
# User-facing wrapper — triggers Touch ID via sudo → runs privileged bw operation
sudo /usr/local/bin/bw-vault-unlock "$@"

View file

@ -0,0 +1,192 @@
#!/bin/bash
# bw-vault-setup — One-time setup for bw-vault (Vaultwarden CLI integration)
# Run this script manually: bash ~/.local/bin/bw-vault-setup
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*" >&2; }
# Check we're running as the user (not root)
if [[ $EUID -eq 0 ]]; then
error "Run this script as your user (not sudo). It will prompt for sudo when needed."
exit 1
fi
echo "============================================"
echo " bw-vault Setup — Vaultwarden CLI for Claude Code"
echo "============================================"
echo ""
# Step 1: Fix Homebrew permissions if needed
info "Checking Homebrew..."
if ! brew --version >/dev/null 2>&1; then
error "Homebrew not found. Install it first: https://brew.sh"
exit 1
fi
# Check if brew dirs are writable
if [[ ! -w /opt/homebrew/Cellar ]]; then
warn "Homebrew directories not writable. Fixing ownership..."
sudo chown -R "$(whoami)" /opt/homebrew
fi
# Step 2: Install dependencies
info "Installing bitwarden-cli and pam-reattach..."
brew install bitwarden-cli pam-reattach 2>/dev/null || {
# May already be installed
brew upgrade bitwarden-cli pam-reattach 2>/dev/null || true
}
# Verify installations
if ! command -v bw >/dev/null; then
error "bitwarden-cli installation failed"
exit 1
fi
info "bw CLI version: $(bw --version)"
if [[ ! -f /opt/homebrew/lib/pam/pam_reattach.so ]]; then
error "pam-reattach installation failed"
exit 1
fi
info "pam-reattach installed"
# Step 3: Configure Touch ID for sudo (with tmux support)
info "Configuring Touch ID for sudo..."
SUDO_LOCAL="/etc/pam.d/sudo_local"
if [[ -f "$SUDO_LOCAL" ]] && grep -q pam_tid "$SUDO_LOCAL"; then
info "Touch ID for sudo already configured"
else
sudo bash -c "cat > $SUDO_LOCAL << 'PAMEOF'
# pam-reattach: required for Touch ID to work in tmux/screen
auth optional /opt/homebrew/lib/pam/pam_reattach.so
auth sufficient pam_tid.so
PAMEOF"
info "Created $SUDO_LOCAL"
fi
# Step 4: Configure scoped sudo for bw-vault
info "Configuring scoped sudo for bw-vault..."
SUDOERS_FILE="/etc/sudoers.d/bw-vault"
if [[ -f "$SUDOERS_FILE" ]]; then
info "Sudoers config already exists"
else
CURRENT_USER=$(whoami)
sudo bash -c "cat > $SUDOERS_FILE << SUDOEOF
Cmnd_Alias BW_VAULT = /usr/local/bin/bw-vault-unlock
Defaults!BW_VAULT timestamp_timeout=0
$CURRENT_USER ALL=(root) BW_VAULT
SUDOEOF
chmod 0440 $SUDOERS_FILE"
# Validate sudoers syntax
if sudo visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then
info "Sudoers config validated and installed"
else
error "Sudoers config has syntax errors — removing"
sudo rm -f "$SUDOERS_FILE"
exit 1
fi
fi
# Step 5: Create root bw data directory
info "Creating root bw data directory..."
sudo bash -c 'mkdir -p /var/root/.bw-data && chmod 700 /var/root/.bw-data'
# Step 6: Configure bw CLI server URL (as root)
info "Configuring Bitwarden CLI server URL..."
sudo BITWARDENCLI_APPDATA_DIR=/var/root/.bw-data /opt/homebrew/bin/bw config server https://vaultwarden.viktorbarzin.me
# Step 7: Store API credentials
info "Setting up Vaultwarden API credentials..."
echo ""
echo "You need your Vaultwarden Personal API Key."
echo "Get it from: https://vaultwarden.viktorbarzin.me/#/settings/security/security-keys"
echo " → API Key section → View API Key"
echo ""
if sudo test -f /var/root/.bw-credentials; then
read -rp "Credentials file already exists. Overwrite? [y/N]: " OVERWRITE
if [[ "${OVERWRITE,,}" != "y" ]]; then
info "Keeping existing credentials"
else
_store_creds=true
fi
else
_store_creds=true
fi
if [[ "${_store_creds:-}" == "true" ]]; then
read -rp "BW_CLIENTID (e.g. user.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx): " _clientid
read -rp "BW_CLIENTSECRET: " _clientsecret
read -rsp "Master password: " _password
echo ""
sudo bash -c "umask 077; cat > /var/root/.bw-credentials << CREDEOF
BW_CLIENTID=$_clientid
BW_CLIENTSECRET=$_clientsecret
BW_PASSWORD=$_password
CREDEOF"
unset _clientid _clientsecret _password
info "Credentials stored in /var/root/.bw-credentials (root:wheel 0600)"
fi
# Step 8: Install the privileged unlock script
info "Installing bw-vault-unlock to /usr/local/bin/..."
sudo cp ~/.local/bin/bw-vault-unlock /usr/local/bin/bw-vault-unlock
sudo chown root:wheel /usr/local/bin/bw-vault-unlock
sudo chmod 0755 /usr/local/bin/bw-vault-unlock
# Step 9: Pin bw binary hash
info "Pinning bw binary hash..."
BW_HASH=$(shasum -a 256 /opt/homebrew/bin/bw | awk '{print $1}')
sudo bash -c "echo '$BW_HASH' > /var/root/.bw-hash && chmod 600 /var/root/.bw-hash"
info "Hash: $BW_HASH"
# Step 10: Verify
echo ""
echo "============================================"
echo " Verification"
echo "============================================"
echo ""
# Test sudo Touch ID
info "Testing sudo (Touch ID should appear)..."
if sudo echo "sudo works"; then
info "sudo + Touch ID: OK"
else
warn "sudo test failed"
fi
# Test credential file is protected
if cat /var/root/.bw-credentials 2>/dev/null; then
error "SECURITY: credentials file is readable by user!"
else
info "Credential file protected: OK"
fi
# Test bw-vault search
info "Testing bw-vault search (Touch ID will appear)..."
if bw-vault search "test" >/dev/null 2>&1; then
info "bw-vault search: OK"
else
warn "bw-vault search test failed (may be normal if vault is empty)"
fi
echo ""
info "Setup complete!"
echo ""
echo "Usage:"
echo " bw-vault search <query> # Search (metadata only)"
echo " bw-vault inject <id> --as <VAR> -- <cmd...> # Inject secret into command"
echo " bw-vault copy <id> [field] # Copy to clipboard"
echo " bw-vault file <id> <path> # Write to file (0600)"
echo " bw-vault create # Create new item"
echo " bw-vault edit <id> # Edit item"
echo ""
echo "After brew upgrade bitwarden-cli, update the hash:"
echo " sudo bash -c \"\$(shasum -a 256 /opt/homebrew/bin/bw | awk '{print \\\$1}') > /var/root/.bw-hash\""