#!/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 [field] [--collection ] [--personal|--all] [--json] bw-secrets list [search] [--collection ] [--personal|--all] bw-secrets env [field] [--collection ] [--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 [search] bw-secrets org get [field] [--json] Fields: username, password (default), notes, totp, url, custom:, 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 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 [field] [--collection ] [--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 [field] [--collection ] [--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 ...\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 [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 [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()