Initial release: bw-secrets agent-friendly Bitwarden/Vaultwarden helper
Single-file pure-Python tool for retrieving secrets from a self-hosted Bitwarden/Vaultwarden vault. Org-first by default, agent exit codes (0/1/2), no session file on disk, no jq/pexpect dependencies. Includes: install.sh, README with org setup walkthrough and agent onboarding prompt, SKILL.md agent documentation.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
# bw-secrets — Agent-friendly Bitwarden/Vaultwarden secrets helper
|
||||
|
||||
Single-file, pure-Python (stdlib only) helper for retrieving secrets from a
|
||||
self-hosted Bitwarden/Vaultwarden vault. Designed for **agents** (AI coding
|
||||
agents, kanban workers, cron jobs) and humans alike.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The `bw` CLI is powerful but not agent-friendly: it needs a session token,
|
||||
serves a stale local cache, and fails silently on non-ASCII item names.
|
||||
`bw-secrets` wraps it with:
|
||||
|
||||
- **Auto-sync before every read** (the CLI serves a local cache)
|
||||
- **Lookup by id, never by name** (avoids the em-dash silent-failure bug)
|
||||
- **Agent exit codes**: `0` found, `1` not found, `2` ambiguous match
|
||||
- **No session file on disk** — re-unlocks per invocation, token stays in-process
|
||||
- **No jq, no pexpect** — pure Python 3 stdlib
|
||||
- **Org-first by default** — `get`/`list`/`env` search the default org's
|
||||
collections; personal-vault items require `--personal` (or `--all`)
|
||||
- **Org-aware, case-insensitive collection lookup**
|
||||
|
||||
## Organizations by default
|
||||
|
||||
`bw-secrets` is **org-first**: secrets for agents belong in an organization,
|
||||
not in a personal vault. This gives you shared access control, collections,
|
||||
and auditability — a personal vault is a single-user silo with none of that.
|
||||
|
||||
By default, `get`, `list`, and `env` search only the default org
|
||||
(`BW_ORG_ID`). Personal-vault items are excluded unless you ask for them:
|
||||
|
||||
```bash
|
||||
bw-secrets get <name> # searches the default org only
|
||||
bw-secrets get <name> --personal # searches personal vault items only
|
||||
bw-secrets get <name> --all # searches everything
|
||||
```
|
||||
|
||||
### Setting up an org for your agent (walkthrough)
|
||||
|
||||
If you don't have an org yet, here's the recommended setup. The pattern:
|
||||
**one dedicated user account per agent**, added to the org with access to
|
||||
only the collections it needs.
|
||||
|
||||
1. **Create the agent's Bitwarden account** (a dedicated user, e.g.
|
||||
`agent-name@yourdomain.com` — never share your personal account with an
|
||||
agent):
|
||||
[Create a Bitwarden account](https://bitwarden.com/help/create-bitwarden-account/)
|
||||
|
||||
2. **Create an organization** (free for personal use; this is where shared
|
||||
secrets live):
|
||||
[Getting started with organizations](https://bitwarden.com/help/getting-started-organizations/) ·
|
||||
[About organizations](https://bitwarden.com/help/about-organizations/)
|
||||
|
||||
3. **Invite the agent's user account to the org** and assign it a role
|
||||
(User is enough for read access; Owner/Admin only if the agent must manage
|
||||
the org):
|
||||
[Manage users in your organization](https://bitwarden.com/help/managing-users/)
|
||||
|
||||
4. **Create collections** for the agent's secret types (e.g.
|
||||
`infrastructure`, `applications`, `credentials`, `devops`):
|
||||
[Create collections](https://bitwarden.com/help/create-collections/)
|
||||
|
||||
5. **Assign the agent user to the collections** it needs and set
|
||||
permissions (Read-only is the safe default for an agent):
|
||||
[Assign users to collections](https://bitwarden.com/help/assign-users-to-collections/) ·
|
||||
[Collection permissions](https://bitwarden.com/help/collection-permissions/)
|
||||
|
||||
6. **Point `bw-secrets` at the org**: set `BW_ORG_ID` to the new org's id in
|
||||
`~/.config/bw/config.env` (or export it), then verify with
|
||||
`bw-secrets org collections`.
|
||||
|
||||
### Self-hosted option: Vaultwarden
|
||||
|
||||
Don't want to pay for Bitwarden or send secrets to their cloud? **Vaultwarden**
|
||||
is a free, open-source, self-hosted Bitwarden-compatible server — drop-in
|
||||
compatible with the `bw` CLI and this tool. Just set `BW_SERVER` to your
|
||||
instance URL.
|
||||
|
||||
- [Vaultwarden on GitHub](https://github.com/dani-garcia/vaultwarden)
|
||||
- [Self-host Bitwarden (official docs)](https://bitwarden.com/help/self-host-bitwarden/) ·
|
||||
[Self-host an organization](https://bitwarden.com/help/self-host-an-organization/)
|
||||
|
||||
## Requirements
|
||||
|
||||
- `bw` CLI (Bitwarden CLI) — on PATH
|
||||
- Master password file (default `~/.config/bw/master_pw`, chmod 600)
|
||||
- Python 3.8+
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
./install.sh # copies bw-secrets to ~/.local/bin/bw-secrets
|
||||
```
|
||||
|
||||
Or manually: copy `bw-secrets` to `~/.local/bin/`, `chmod 700`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All values are overridable via environment variables or
|
||||
`~/.config/bw/config.env` (highest wins: env var > config file > default).
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `BW_SERVER` | *(required)* | Vaultwarden URL |
|
||||
| `BW_ORG_ID` | *(required for org-first)* | Default org id |
|
||||
| `BW_MASTER_PW_FILE` | `~/.config/bw/master_pw` | Master password file |
|
||||
| `BW_BIN` | `bw` | bw CLI path |
|
||||
|
||||
```bash
|
||||
# ~/.config/bw/config.env (chmod 600)
|
||||
BW_SERVER=https://vault.example.com
|
||||
BW_ORG_ID=<your-org-id>
|
||||
BW_MASTER_PW_FILE=/path/to/master_pw
|
||||
```
|
||||
|
||||
## Agent onboarding (copy-paste prompt)
|
||||
|
||||
Paste the block below to any agent to configure this tool interactively. The
|
||||
agent should run each step, confirm the result, and only proceed on success.
|
||||
|
||||
````text
|
||||
You are configuring the `bw-secrets` tool — an agent-friendly wrapper around
|
||||
the Bitwarden CLI for retrieving secrets from a self-hosted vault. Work
|
||||
through these steps in order, running every command and reporting the result
|
||||
of each before moving on.
|
||||
|
||||
STEP 1 — Install
|
||||
Clone the repo to a temp dir, run ./install.sh, then remove the clone:
|
||||
git clone <repo-url> /tmp/bw-secrets-setup
|
||||
cd /tmp/bw-secrets-setup && ./install.sh
|
||||
cd / && rm -rf /tmp/bw-secrets-setup
|
||||
Confirm: `~/.local/bin/bw-secrets` exists and is executable (chmod 700).
|
||||
|
||||
STEP 2 — Configure
|
||||
Create ~/.config/bw/config.env (chmod 600) with:
|
||||
BW_SERVER=<vault-url>
|
||||
BW_ORG_ID=<org-id>
|
||||
BW_MASTER_PW_FILE=<path-to-master-password-file>
|
||||
Ask the user for these values if they are not already set. Do NOT ask for or
|
||||
store the master password itself — it must never be typed or stored anywhere
|
||||
except the master password file.
|
||||
|
||||
STEP 3 — Verify the environment
|
||||
Run `bw-secrets status`. You should see JSON with "status":"locked" and the
|
||||
serverUrl you configured. If `bw` is not found, install the Bitwarden CLI and
|
||||
ensure it is on PATH.
|
||||
|
||||
STEP 4 — Test retrieval (live vault)
|
||||
Run these and confirm each returns a value (do not print full secret values —
|
||||
show only the first 6 and last 4 characters):
|
||||
bw-secrets org list
|
||||
bw-secrets org collections
|
||||
bw-secrets list --all
|
||||
bw-secrets get <some-known-item> --all username
|
||||
If any command errors with "could not parse", re-run it — the tool retries
|
||||
internally, but a stale session can still fail once; a second run succeeds.
|
||||
|
||||
STEP 5 — Verify exit codes (agent contract)
|
||||
bw-secrets get "definitely-not-a-real-item-xyz"; echo $? # expect: 1
|
||||
bw-secrets get "a"; echo $? # expect: 2 (ambiguous)
|
||||
bw-secrets get <some-known-item> --all >/dev/null; echo $? # expect: 0
|
||||
|
||||
When all five steps pass, report: "bw-secrets configured and verified —
|
||||
install OK, retrieval OK, exit codes 0/1/2 OK." If any step fails, stop and
|
||||
report the exact error rather than guessing.
|
||||
````
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Get a secret (password is default field) — searches the default org
|
||||
bw-secrets get <name>
|
||||
bw-secrets get <name> username
|
||||
bw-secrets get <name> custom:<field>
|
||||
bw-secrets get <name> json
|
||||
|
||||
# Search scope (default: org only)
|
||||
bw-secrets get <name> --personal # personal vault items only
|
||||
bw-secrets get <name> --all # everything (org + personal)
|
||||
|
||||
# Scoped to a collection (case-insensitive)
|
||||
bw-secrets get <name> --collection Infrastructure
|
||||
bw-secrets org get Infrastructure <name> [field]
|
||||
|
||||
# List / search
|
||||
bw-secrets list [search]
|
||||
bw-secrets list [search] --personal
|
||||
bw-secrets org items Infrastructure [search]
|
||||
|
||||
# Org / collection discovery
|
||||
bw-secrets org list
|
||||
bw-secrets org collections
|
||||
|
||||
# Environment-style output (for sourcing)
|
||||
bw-secrets env <name> [field]
|
||||
|
||||
# Maintenance
|
||||
bw-secrets sync
|
||||
bw-secrets status
|
||||
bw-secrets unlock
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `password` (default) | Login password |
|
||||
| `username` | Login username |
|
||||
| `notes` | Item notes |
|
||||
| `totp` | TOTP URI |
|
||||
| `url` | First login URI |
|
||||
| `custom:<name>` | Custom field by name |
|
||||
| `json` | Full item JSON |
|
||||
| `<dotted.path>` | Any dotted path into the item, e.g. `login.uris.0.uri` |
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Found, value printed to stdout |
|
||||
| `1` | Not found / error |
|
||||
| `2` | Ambiguous match (multiple items) — refine your search |
|
||||
|
||||
## Security rules
|
||||
|
||||
- **Secrets ALWAYS live in Bitwarden.** This tool only reads them at runtime.
|
||||
- **Never write the session token to disk.** Re-unlock per invocation.
|
||||
- **Never echo secrets to logs.** Use `set +x` around secret handling.
|
||||
- **Never commit `.env` files or credentials.**
|
||||
- The master password file is the ONLY secret on disk (chmod 600).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
# edit bw-secrets, test against the live vault, commit, push
|
||||
```
|
||||
|
||||
See `SKILL.md` for the agent-facing usage documentation.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: bitwarden-secrets
|
||||
description: Retrieve secrets from a self-hosted Bitwarden/Vaultwarden vault via the bw-secrets helper. Use when you need API keys, tokens, passwords, database credentials, or any sensitive value. Auto-syncs before reads, agent-friendly exit codes, no session file on disk.
|
||||
version: 3.0.0
|
||||
---
|
||||
|
||||
# Bitwarden Secrets Skill
|
||||
|
||||
Secure access to a self-hosted Bitwarden/Vaultwarden vault for retrieving
|
||||
secrets at runtime.
|
||||
|
||||
## When to use
|
||||
|
||||
- You need an API key, token, password, or credential for any service
|
||||
- You need to know what secrets exist and where they live
|
||||
- You are writing a script, cron job, or agent flow that needs a secret
|
||||
|
||||
## ⚠️ Standing rules
|
||||
|
||||
1. **Secrets ALWAYS live in Bitwarden.** Never `.env`, never config.yaml,
|
||||
never plaintext anywhere. Fetch at runtime.
|
||||
2. **ALL items go in an org vault**, never the personal vault.
|
||||
3. **ALWAYS `bw sync` before reads** — the CLI serves a local cache; external
|
||||
updates are invisible until sync. `bw-secrets` does this automatically.
|
||||
4. **Look items up by id, never by name** — em-dash names cause silent 0-byte
|
||||
responses from `bw get item`. `bw-secrets` handles this internally.
|
||||
5. **Never write the session token to disk.** Re-unlock per invocation.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
# Get a secret (password is default) — searches the default org
|
||||
bw-secrets get <name>
|
||||
bw-secrets get <name> username
|
||||
bw-secrets get <name> custom:<field>
|
||||
bw-secrets get <name> json
|
||||
|
||||
# Search scope (default: org only)
|
||||
bw-secrets get <name> --personal # personal vault items only
|
||||
bw-secrets get <name> --all # everything (org + personal)
|
||||
|
||||
# Scoped to a collection (case-insensitive)
|
||||
bw-secrets get <name> --collection Infrastructure
|
||||
bw-secrets org get Infrastructure <name> [field]
|
||||
|
||||
# List / search
|
||||
bw-secrets list [search]
|
||||
bw-secrets list [search] --personal
|
||||
bw-secrets org items Infrastructure [search]
|
||||
|
||||
# Discovery
|
||||
bw-secrets org list
|
||||
bw-secrets org collections
|
||||
|
||||
# Environment-style output (for sourcing)
|
||||
bw-secrets env <name> [field]
|
||||
|
||||
# Maintenance
|
||||
bw-secrets sync
|
||||
bw-secrets status
|
||||
bw-secrets unlock
|
||||
```
|
||||
|
||||
## Exit codes (agent contract)
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Found, value on stdout |
|
||||
| `1` | Not found / error |
|
||||
| `2` | Ambiguous match — refine the search |
|
||||
|
||||
## Org-first behavior
|
||||
|
||||
`bw-secrets` is **org-first** — `get`/`list`/`env` search the default org's
|
||||
collections by default. Personal-vault items (no collection) require
|
||||
`--personal` or `--all`:
|
||||
|
||||
```bash
|
||||
bw-secrets get "some-personal-item" --personal username
|
||||
```
|
||||
|
||||
Org membership is determined by collectionIds intersecting the org's
|
||||
collection IDs — NOT by item.organizationId, which is null on items created
|
||||
via `bw create item`.
|
||||
|
||||
## The Python pattern (primary agent interface)
|
||||
|
||||
For agents, the canonical way to use a secret is a `get_secret()` helper —
|
||||
it never puts the secret in a shell command line, log, or process listing:
|
||||
|
||||
```python
|
||||
import json, subprocess, os
|
||||
|
||||
def get_secret(substring, collection_id=None, field='password'):
|
||||
"""Fetch a secret from Bitwarden by item-name substring."""
|
||||
env = {**os.environ, 'PATH': '/usr/bin:/bin'}
|
||||
session = subprocess.run(
|
||||
['bw', 'unlock', '--passwordfile', '<master-pw-file>', '--raw'],
|
||||
capture_output=True, text=True, env=env, check=True,
|
||||
).stdout.strip()
|
||||
items = json.loads(subprocess.run(
|
||||
['bw', 'list', 'items', '--session', session],
|
||||
capture_output=True, text=True, env=env, check=True,
|
||||
).stdout)
|
||||
matches = [i for i in items if substring.lower() in i.get('name','').lower()]
|
||||
if collection_id is not None:
|
||||
scoped = [i for i in matches if collection_id in i.get('collectionIds', [])]
|
||||
if scoped:
|
||||
matches = scoped
|
||||
for it in matches:
|
||||
item = json.loads(subprocess.run(
|
||||
['bw', 'get', 'item', it['id'], '--session', session],
|
||||
capture_output=True, text=True, env=env, check=True,
|
||||
).stdout)
|
||||
if field == 'password':
|
||||
return item['login']['password']
|
||||
if field in [f['name'] for f in item.get('fields', [])]:
|
||||
return {f['name']: f['value'] for f in item['fields']}[field]
|
||||
return item
|
||||
raise KeyError(f"No item matching {substring!r}")
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Get a token for an API call
|
||||
|
||||
```bash
|
||||
# Inline — secret never stored in a variable
|
||||
curl -H "Authorization: token $(bw-secrets get 'my-token')" \
|
||||
https://api.example.com/v1/user
|
||||
```
|
||||
|
||||
### Get a credential from a collection
|
||||
|
||||
```bash
|
||||
bw-secrets org get Infrastructure "my-server" username
|
||||
bw-secrets org get Infrastructure "my-server" password
|
||||
bw-secrets org get Applications "my-app" custom:URL
|
||||
```
|
||||
|
||||
### Generate an env file (only when absolutely necessary)
|
||||
|
||||
```bash
|
||||
bw-secrets sync
|
||||
cat > .env << EOF
|
||||
DATABASE_URL=$(bw-secrets get my-db url)
|
||||
API_KEY=$(bw-secrets get my-api password)
|
||||
EOF
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
### In a deployment script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# ✅ CORRECT — inline usage, secret never stored
|
||||
ssh $(bw-secrets get production-server username)@server.com \
|
||||
"docker login -u $(bw-secrets get docker-hub username) -p $(bw-secrets get docker-hub password) registry.com"
|
||||
```
|
||||
|
||||
## Security guidelines
|
||||
|
||||
### DO
|
||||
|
||||
- Sync before every read (auto-synced in `bw-secrets`)
|
||||
- Use secrets inline in commands — never persist to disk, never echo
|
||||
- Use the helper script for all vault operations
|
||||
- Use appropriate collections for different secret types
|
||||
- Use `set +x` before commands that handle secrets
|
||||
|
||||
### DON'T
|
||||
|
||||
- Never write the session token to disk
|
||||
- Never log secret values in plain text
|
||||
- Never commit `.env` files or credentials
|
||||
- Never share secrets in chat or documentation
|
||||
- Never store secrets in git repositories
|
||||
- Never hardcode credentials in scripts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `ERROR: no items found matching 'X'` | Item doesn't exist, or is in a different collection | `bw-secrets list X --all` to search everything |
|
||||
| `ERROR: ambiguous match for 'X'` | Multiple items match | Add `--collection <name>` or refine the name |
|
||||
| `ERROR: collection 'X' not found` | Wrong collection name | `bw-secrets org collections` to list real names |
|
||||
| `ERROR: master password file not found` | Wrong path | Set `BW_MASTER_PW_FILE` |
|
||||
| `bw: command not found` | Not on PATH | Install the Bitwarden CLI and add it to PATH |
|
||||
| `ERROR: BW_SERVER is not set` | Missing config | Set `BW_SERVER` in `~/.config/bw/config.env` |
|
||||
| Stale values after external update | CLI local cache | `bw-secrets sync` (auto-done on every get/list) |
|
||||
|
||||
## Development
|
||||
|
||||
Repo: `bitwarden-secrets` (self-hosted git).
|
||||
Single-file tool: `bw-secrets` (pure Python 3, stdlib only).
|
||||
Install: `./install.sh` → `~/.local/bin/bw-secrets`.
|
||||
Executable
+530
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bw-secrets — Agent-friendly Bitwarden/Vaultwarden secrets helper.
|
||||
|
||||
Pure Python 3 (stdlib only). No jq, no pexpect, no session file on disk.
|
||||
|
||||
Design rules:
|
||||
* Secrets ALWAYS live in Bitwarden. This tool only reads them at runtime.
|
||||
* Master password file (chmod 600) — the ONLY secret on disk. Never write
|
||||
the session token to disk; re-unlock per call.
|
||||
* ALWAYS `bw sync` before reads (the CLI serves a local cache).
|
||||
* Look items up by id, never by name (em-dash silent-failure bug).
|
||||
* Agent-friendly: exit 0 = found, 1 = not found, 2 = ambiguous match.
|
||||
* ORG-FIRST: get/list/env search the default org (BW_ORG_ID) by default.
|
||||
Personal-vault items are excluded unless --personal; --all searches
|
||||
everything. Collections matched case-insensitively.
|
||||
|
||||
Config resolution (highest wins): environment variable > config file
|
||||
(~/.config/bw/config.env) > built-in default.
|
||||
|
||||
BW_SERVER Vaultwarden URL (required — no default)
|
||||
BW_ORG_ID Default org id (required for org-first lookups)
|
||||
BW_MASTER_PW_FILE Master password file (default ~/.config/bw/master_pw)
|
||||
BW_BIN bw CLI path (default bw on PATH)
|
||||
|
||||
Usage:
|
||||
bw-secrets get <name> [field] [--collection <name>] [--personal|--all] [--json]
|
||||
bw-secrets list [search] [--collection <name>] [--personal|--all]
|
||||
bw-secrets env <name> [field] [--collection <name>] [--personal|--all] # prints KEY=value for sourcing
|
||||
bw-secrets sync | status | unlock
|
||||
bw-secrets org list
|
||||
bw-secrets org collections [org-id]
|
||||
bw-secrets org items <collection> [search]
|
||||
bw-secrets org get <collection> <name> [field] [--json]
|
||||
|
||||
Fields: username, password (default), notes, totp, url, custom:<field>, json,
|
||||
or a dotted path into the item (e.g. login.uris.0.uri).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Config
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
DEFAULTS = {
|
||||
"BW_SERVER": "",
|
||||
"BW_ORG_ID": "",
|
||||
"BW_MASTER_PW_FILE": str(Path.home() / ".config" / "bw" / "master_pw"),
|
||||
"BW_BIN": "bw",
|
||||
}
|
||||
|
||||
CONFIG_FILE = Path.home() / ".config" / "bw" / "config.env"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = dict(DEFAULTS)
|
||||
if CONFIG_FILE.exists():
|
||||
for line in CONFIG_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
cfg[k.strip()] = v.strip()
|
||||
for k in DEFAULTS:
|
||||
if os.environ.get(k):
|
||||
cfg[k] = os.environ[k]
|
||||
return cfg
|
||||
|
||||
|
||||
CFG = load_config()
|
||||
|
||||
|
||||
def require(key: str, hint: str) -> str:
|
||||
val = CFG.get(key, "")
|
||||
if not val:
|
||||
sys.stderr.write(
|
||||
f"ERROR: {key} is not set. {hint}\n"
|
||||
f"Set it in {CONFIG_FILE} or export it as an environment variable.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
return val
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# bw plumbing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _env() -> dict:
|
||||
env = os.environ.copy()
|
||||
if CFG.get("BW_SERVER"):
|
||||
env["BW_SERVER"] = CFG["BW_SERVER"]
|
||||
return env
|
||||
|
||||
|
||||
def _bw(args, session=None, check=True):
|
||||
"""Run bw with the given args. Returns (returncode, stdout)."""
|
||||
cmd = [CFG["BW_BIN"]] + args
|
||||
if session:
|
||||
cmd += ["--session", session]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=_env(), timeout=60)
|
||||
if check and proc.returncode != 0:
|
||||
err = proc.stderr.strip() or proc.stdout.strip()
|
||||
sys.stderr.write(f"bw error: {err}\n")
|
||||
sys.exit(1)
|
||||
return proc.returncode, proc.stdout
|
||||
|
||||
|
||||
def unlock() -> str:
|
||||
"""Unlock the vault, return session token. Never persists the token."""
|
||||
pw_file = Path(require("BW_MASTER_PW_FILE",
|
||||
"Point BW_MASTER_PW_FILE at your master password file."))
|
||||
if not pw_file.exists():
|
||||
sys.stderr.write(f"ERROR: master password file not found: {pw_file}\n")
|
||||
sys.exit(1)
|
||||
proc = subprocess.run(
|
||||
[CFG["BW_BIN"], "unlock", "--passwordfile", str(pw_file), "--raw"],
|
||||
capture_output=True, text=True, env=_env(), timeout=60,
|
||||
)
|
||||
session = proc.stdout.strip()
|
||||
if not session or proc.returncode != 0:
|
||||
sys.stderr.write("ERROR: bw unlock failed\n")
|
||||
sys.exit(1)
|
||||
return session
|
||||
|
||||
|
||||
def sync(session: str) -> None:
|
||||
_bw(["sync"], session=session, check=False)
|
||||
|
||||
|
||||
def _list_json(session: str, what: str, retries: int = 3) -> list:
|
||||
"""bw list <what> with retry. The CLI intermittently returns empty stdout
|
||||
(verified 2026-09-10: `bw list items` returned 0 bytes on ~5% of runs).
|
||||
Retry with a fresh session before giving up."""
|
||||
for attempt in range(1, retries + 1):
|
||||
_, out = _bw(["list", what], session=session, check=False)
|
||||
try:
|
||||
return json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
if attempt < retries:
|
||||
session = unlock() # fresh session — stale token can cause empty output
|
||||
continue
|
||||
sys.stderr.write(f"ERROR: could not parse bw list {what} output "
|
||||
f"(empty={len(out) == 0})\n")
|
||||
sys.exit(1)
|
||||
return [] # unreachable
|
||||
|
||||
|
||||
def list_items(session: str) -> list:
|
||||
"""Unfiltered item list (catches collection-only items the org filter misses)."""
|
||||
return _list_json(session, "items")
|
||||
|
||||
|
||||
def list_collections(session: str) -> list:
|
||||
return _list_json(session, "collections")
|
||||
|
||||
|
||||
def list_orgs(session: str) -> list:
|
||||
return _list_json(session, "organizations")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def find_collection_id(collections: list, name: str, org_id: str = None) -> str:
|
||||
"""Case-insensitive collection lookup, preferring the target org."""
|
||||
name_l = name.lower()
|
||||
candidates = [c for c in collections if c.get("name", "").lower() == name_l]
|
||||
if not candidates:
|
||||
return None
|
||||
if org_id:
|
||||
for c in candidates:
|
||||
if c.get("organizationId") == org_id:
|
||||
return c["id"]
|
||||
return candidates[0]["id"]
|
||||
|
||||
|
||||
def match_items(items: list, name: str, collection_id: str = None) -> list:
|
||||
"""Case-insensitive substring match on item name, optional collection filter."""
|
||||
name_l = name.lower()
|
||||
hits = [it for it in items if name_l in it.get("name", "").lower()]
|
||||
if collection_id:
|
||||
hits = [it for it in hits if collection_id in it.get("collectionIds", [])]
|
||||
return hits
|
||||
|
||||
|
||||
def resolve_scope(items: list, scope: str, org_id: str = None,
|
||||
collections: list = None) -> list:
|
||||
"""Filter items by search scope.
|
||||
|
||||
scope: 'org' (default) — items in the default org's collections
|
||||
'personal' — items with no org/collection (personal vault)
|
||||
'all' — everything
|
||||
|
||||
Org membership is determined by collectionIds intersecting the org's
|
||||
collection IDs — NOT by item.organizationId, which is null on items
|
||||
created via `bw create item` (verified 2026-06-28).
|
||||
"""
|
||||
if scope == "all":
|
||||
return items
|
||||
if scope == "personal":
|
||||
return [it for it in items if not it.get("collectionIds")]
|
||||
# org scope
|
||||
if org_id and collections:
|
||||
org_coll_ids = {c["id"] for c in collections
|
||||
if c.get("organizationId") == org_id}
|
||||
return [it for it in items
|
||||
if org_coll_ids.intersection(it.get("collectionIds", []))]
|
||||
return [it for it in items if it.get("collectionIds")]
|
||||
|
||||
|
||||
def get_field(item: dict, field: str):
|
||||
"""Extract a field from a bw item. Returns (value, is_json)."""
|
||||
if field == "json":
|
||||
return json.dumps(item, indent=2), True
|
||||
if field == "password":
|
||||
return item.get("login", {}).get("password", ""), False
|
||||
if field == "username":
|
||||
return item.get("login", {}).get("username", ""), False
|
||||
if field == "notes":
|
||||
return item.get("notes", ""), False
|
||||
if field == "totp" or field == "totp_uri":
|
||||
return item.get("login", {}).get("totp", ""), False
|
||||
if field == "url":
|
||||
uris = item.get("login", {}).get("uris") or []
|
||||
return (uris[0].get("uri", "") if uris else ""), False
|
||||
if field.startswith("custom:"):
|
||||
fname = field[len("custom:"):]
|
||||
for f in item.get("fields", []):
|
||||
if f.get("name") == fname:
|
||||
return f.get("value", ""), False
|
||||
return "", False
|
||||
# dotted path, e.g. login.uris.0.uri
|
||||
val = item
|
||||
for part in field.split("."):
|
||||
if isinstance(val, list):
|
||||
try:
|
||||
val = val[int(part)]
|
||||
except (ValueError, IndexError):
|
||||
return "", False
|
||||
elif isinstance(val, dict):
|
||||
val = val.get(part)
|
||||
else:
|
||||
return "", False
|
||||
return (val if val is not None else ""), False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def cmd_get(args):
|
||||
if not args:
|
||||
sys.stderr.write("Usage: bw-secrets get <name> [field] [--collection <name>] [--personal|--all] [--json]\n")
|
||||
sys.exit(1)
|
||||
name = args[0]
|
||||
field = "password"
|
||||
collection = None
|
||||
scope = "org"
|
||||
as_json = False
|
||||
i = 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "--collection" and i + 1 < len(args):
|
||||
collection = args[i + 1]
|
||||
i += 2
|
||||
elif a == "--personal":
|
||||
scope = "personal"
|
||||
i += 1
|
||||
elif a == "--all":
|
||||
scope = "all"
|
||||
i += 1
|
||||
elif a == "--json":
|
||||
as_json = True
|
||||
i += 1
|
||||
else:
|
||||
field = a
|
||||
i += 1
|
||||
if as_json:
|
||||
field = "json"
|
||||
|
||||
session = unlock()
|
||||
sync(session)
|
||||
items = list_items(session)
|
||||
collections = list_collections(session)
|
||||
org_id = CFG.get("BW_ORG_ID", "")
|
||||
coll_id = find_collection_id(collections, collection, org_id) if collection else None
|
||||
if collection and not coll_id:
|
||||
sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
|
||||
sys.exit(1)
|
||||
|
||||
if collection:
|
||||
items = [it for it in items if coll_id in it.get("collectionIds", [])]
|
||||
else:
|
||||
items = resolve_scope(items, scope, org_id, collections)
|
||||
|
||||
hits = match_items(items, name)
|
||||
if not hits:
|
||||
sys.stderr.write(f"ERROR: no items found matching '{name}'\n")
|
||||
sys.exit(1)
|
||||
if len(hits) > 1:
|
||||
names = ", ".join(h.get("name", "?") for h in hits)
|
||||
sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
|
||||
sys.exit(2)
|
||||
|
||||
value, is_json = get_field(hits[0], field)
|
||||
if is_json:
|
||||
print(value)
|
||||
else:
|
||||
print(value)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
search = args[0] if args and not args[0].startswith("--") else None
|
||||
collection = None
|
||||
scope = "org"
|
||||
if "--collection" in args:
|
||||
idx = args.index("--collection")
|
||||
if idx + 1 < len(args):
|
||||
collection = args[idx + 1]
|
||||
if "--personal" in args:
|
||||
scope = "personal"
|
||||
if "--all" in args:
|
||||
scope = "all"
|
||||
|
||||
session = unlock()
|
||||
sync(session)
|
||||
items = list_items(session)
|
||||
collections = list_collections(session)
|
||||
org_id = CFG.get("BW_ORG_ID", "")
|
||||
coll_id = find_collection_id(collections, collection, org_id) if collection else None
|
||||
if collection and not coll_id:
|
||||
sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
|
||||
sys.exit(1)
|
||||
|
||||
if collection:
|
||||
items = [it for it in items if coll_id in it.get("collectionIds", [])]
|
||||
else:
|
||||
items = resolve_scope(items, scope, org_id, collections)
|
||||
|
||||
if search:
|
||||
items = [it for it in items if search.lower() in it.get("name", "").lower()]
|
||||
|
||||
for it in sorted(items, key=lambda x: x.get("name", "").lower()):
|
||||
kind = "login" if it.get("type") == 1 else "other"
|
||||
print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}")
|
||||
|
||||
|
||||
def cmd_env(args):
|
||||
if not args:
|
||||
sys.stderr.write("Usage: bw-secrets env <name> [field] [--collection <name>] [--personal|--all]\n")
|
||||
sys.exit(1)
|
||||
name = args[0]
|
||||
field = "password"
|
||||
collection = None
|
||||
scope = "org"
|
||||
i = 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "--collection" and i + 1 < len(args):
|
||||
collection = args[i + 1]
|
||||
i += 2
|
||||
elif a == "--personal":
|
||||
scope = "personal"
|
||||
i += 1
|
||||
elif a == "--all":
|
||||
scope = "all"
|
||||
i += 1
|
||||
else:
|
||||
field = a
|
||||
i += 1
|
||||
|
||||
session = unlock()
|
||||
sync(session)
|
||||
items = list_items(session)
|
||||
collections = list_collections(session)
|
||||
org_id = CFG.get("BW_ORG_ID", "")
|
||||
coll_id = find_collection_id(collections, collection, org_id) if collection else None
|
||||
if collection:
|
||||
items = [it for it in items if coll_id in it.get("collectionIds", [])]
|
||||
else:
|
||||
items = resolve_scope(items, scope, org_id, collections)
|
||||
|
||||
hits = match_items(items, name)
|
||||
if not hits:
|
||||
sys.stderr.write(f"ERROR: no items found matching '{name}'\n")
|
||||
sys.exit(1)
|
||||
if len(hits) > 1:
|
||||
names = ", ".join(h.get("name", "?") for h in hits)
|
||||
sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
|
||||
sys.exit(2)
|
||||
|
||||
value, _ = get_field(hits[0], field)
|
||||
key = re.sub(r"[^A-Z0-9_]", "_", hits[0].get("name", "SECRET").upper())
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
def cmd_sync(_args):
|
||||
session = unlock()
|
||||
sync(session)
|
||||
print("Vault synced.")
|
||||
|
||||
|
||||
def cmd_status(_args):
|
||||
proc = subprocess.run([CFG["BW_BIN"], "status"], capture_output=True, text=True, env=_env(), timeout=30)
|
||||
print(proc.stdout.strip() or proc.stderr.strip())
|
||||
|
||||
|
||||
def cmd_unlock(_args):
|
||||
session = unlock()
|
||||
print(f"Vault unlocked. Session: {session[:8]}...")
|
||||
|
||||
|
||||
def cmd_org(args):
|
||||
if not args:
|
||||
sys.stderr.write("Usage: bw-secrets org <list|collections|items|get> ...\n")
|
||||
sys.exit(1)
|
||||
sub = args[0]
|
||||
rest = args[1:]
|
||||
|
||||
if sub == "list":
|
||||
session = unlock()
|
||||
sync(session)
|
||||
for o in list_orgs(session):
|
||||
print(f"{o.get('name', '?')} [{o.get('id', '?')}]")
|
||||
return
|
||||
|
||||
if sub == "collections":
|
||||
session = unlock()
|
||||
sync(session)
|
||||
org_id = rest[0] if rest else CFG.get("BW_ORG_ID", "")
|
||||
for c in list_collections(session):
|
||||
if c.get("organizationId") == org_id:
|
||||
print(f"{c.get('name', '?')} [{c.get('id', '?')}]")
|
||||
return
|
||||
|
||||
if sub == "items":
|
||||
if not rest:
|
||||
sys.stderr.write("Usage: bw-secrets org items <collection> [search]\n")
|
||||
sys.exit(1)
|
||||
collection = rest[0]
|
||||
search = rest[1] if len(rest) > 1 else None
|
||||
session = unlock()
|
||||
sync(session)
|
||||
collections = list_collections(session)
|
||||
coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", ""))
|
||||
if not coll_id:
|
||||
sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
|
||||
sys.exit(1)
|
||||
items = [it for it in list_items(session) if coll_id in it.get("collectionIds", [])]
|
||||
if search:
|
||||
items = [it for it in items if search.lower() in it.get("name", "").lower()]
|
||||
for it in sorted(items, key=lambda x: x.get("name", "").lower()):
|
||||
kind = "login" if it.get("type") == 1 else "other"
|
||||
print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}")
|
||||
return
|
||||
|
||||
if sub == "get":
|
||||
if len(rest) < 2:
|
||||
sys.stderr.write("Usage: bw-secrets org get <collection> <name> [field] [--json]\n")
|
||||
sys.exit(1)
|
||||
collection = rest[0]
|
||||
name = rest[1]
|
||||
field = "password"
|
||||
as_json = False
|
||||
for a in rest[2:]:
|
||||
if a == "--json":
|
||||
as_json = True
|
||||
else:
|
||||
field = a
|
||||
if as_json:
|
||||
field = "json"
|
||||
session = unlock()
|
||||
sync(session)
|
||||
collections = list_collections(session)
|
||||
coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", ""))
|
||||
if not coll_id:
|
||||
sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
|
||||
sys.exit(1)
|
||||
hits = match_items(list_items(session), name, coll_id)
|
||||
if not hits:
|
||||
sys.stderr.write(f"ERROR: no items found matching '{name}' in collection '{collection}'\n")
|
||||
sys.exit(1)
|
||||
if len(hits) > 1:
|
||||
names = ", ".join(h.get("name", "?") for h in hits)
|
||||
sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
|
||||
sys.exit(2)
|
||||
value, is_json = get_field(hits[0], field)
|
||||
print(value if is_json else value)
|
||||
return
|
||||
|
||||
sys.stderr.write(f"ERROR: unknown org subcommand '{sub}'\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_help(_args=None):
|
||||
print(__doc__)
|
||||
|
||||
|
||||
def main():
|
||||
if not args:
|
||||
cmd_help()
|
||||
return
|
||||
cmd = args[0]
|
||||
rest = args[1:]
|
||||
handlers = {
|
||||
"get": cmd_get,
|
||||
"list": cmd_list,
|
||||
"env": cmd_env,
|
||||
"sync": cmd_sync,
|
||||
"status": cmd_status,
|
||||
"unlock": cmd_unlock,
|
||||
"org": cmd_org,
|
||||
"help": cmd_help,
|
||||
"--help": cmd_help,
|
||||
"-h": cmd_help,
|
||||
}
|
||||
if cmd not in handlers:
|
||||
sys.stderr.write(f"ERROR: unknown command '{cmd}'. Run 'bw-secrets help'.\n")
|
||||
sys.exit(1)
|
||||
handlers[cmd](rest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
main()
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# install.sh — Install bw-secrets to ~/.local/bin
|
||||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "$0")" && pwd)/bw-secrets"
|
||||
DEST="${HOME}/.local/bin/bw-secrets"
|
||||
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
install -m 700 "${SRC}" "${DEST}"
|
||||
echo "Installed bw-secrets → ${DEST}"
|
||||
|
||||
# Ensure ~/.local/bin is on PATH
|
||||
case ":${PATH}:" in
|
||||
*":${HOME}/.local/bin:"*) ;;
|
||||
*) echo "NOTE: add ${HOME}/.local/bin to your PATH" ;;
|
||||
esac
|
||||
|
||||
# Optional: create config dir with template
|
||||
CONFIG_DIR="${HOME}/.config/bw"
|
||||
if [[ ! -f "${CONFIG_DIR}/config.env" ]]; then
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
cat > "${CONFIG_DIR}/config.env" <<'EOF'
|
||||
# bw-secrets configuration (chmod 600)
|
||||
# BW_SERVER and BW_ORG_ID are required — set them to your vault.
|
||||
# BW_SERVER=https://vault.example.com
|
||||
# BW_ORG_ID=<your-org-id>
|
||||
# BW_MASTER_PW_FILE=${HOME}/.config/bw/master_pw
|
||||
EOF
|
||||
chmod 600 "${CONFIG_DIR}/config.env"
|
||||
echo "Created config template → ${CONFIG_DIR}/config.env"
|
||||
fi
|
||||
|
||||
echo "Done. Set BW_SERVER and BW_ORG_ID in ${CONFIG_DIR}/config.env, then test with: bw-secrets status"
|
||||
Reference in New Issue
Block a user