#!/usr/bin/env python3 """Manage the kbots vault — add, view, delete secrets.""" from pathlib import Path from src.core.base import resolve_config_file, resolve_vault_key_file from src.vault.fernet import FernetVault, _normalise_key, describe_value_faults KEY_FILE = resolve_vault_key_file() # The canonical name for every secret Core tools read. Keep this in step with # the vault.get() call sites: a key missing from here is a key an operator has # to guess, and guessing is what this list exists to prevent. Deployments can # still add their own via "Custom key" — per-bot tokens like # "discord-token-atlas" are looked up dynamically and belong nowhere in here. ALL_KEYS = [ # Discord (one per bot account) "discord-token", # AI / LLM "secrets/gemini-api-key", "secrets/groq-api-key", # Google Workspace (OAuth2) "secrets/google-api-credentials.json", # Web search "secrets/tavily-api-key", "secrets/serpapi-key", # Integrations "secrets/cloudflare-api-token", "secrets/notion-api-key", "secrets/trello-credentials.json", # Messaging "secrets/slack-bot-token", "secrets/twilio-account-sid", "secrets/twilio-auth-token", "secrets/twilio-from-number", # GitHub "github-token", ] def canonical_for(key: str) -> str | None: """The canonical spelling of `key`, if it is a known secret spelt oddly. Catches the mistake at the point it is made: someone types "cloudflare-api-token", it saves fine, and days later every Cloudflare tool says "not configured" while the value sits in the vault under a near-identical name. Note that a returned canonical name does NOT mean the typed one is dead. Some — CLOUDFLARE_API_TOKEN, GITHUB_TOKEN — are read as explicit fallbacks and work fine. They are still worth converging on one spelling, because two live spellings is how you end up updating the copy nothing reads. Callers must phrase this as "not canonical", never as "nothing reads this". Deliberately narrow. It only fires when the typed name normalises to exactly one entry in ALL_KEYS and differs from it, so genuinely custom keys — per-bot tokens, deployment-specific secrets, anything looked up dynamically — are never second-guessed. """ if key in ALL_KEYS: return None matches = {k for k in ALL_KEYS if _normalise_key(k) == _normalise_key(key)} return matches.pop() if len(matches) == 1 else None def get_vault(): # Resolve the SAME vault the service reads (overlay → modules → core), not a # CWD-relative path — otherwise secrets added from the wrong dir silently go # to a stray secrets.enc the service never loads. v = FernetVault(str(resolve_config_file("secrets.enc"))) v.unlock(KEY_FILE.read_text().strip()) return v def cmd_list(v): keys = sorted(v.list_keys()) print(f"\n Vault: {len(keys)} secrets\n") for k in keys: print(f" {k}") print() def cmd_add(v): # Show every secret already in the vault (pick one to update), then the # common keys not yet set (suggestions to add). Previously only the fixed # ALL_KEYS template was listed, so existing custom secrets were invisible. existing = sorted(v.list_keys()) suggested = [k for k in ALL_KEYS if k not in existing] options = existing + suggested print("\n Keys (* = already set — pick a number to update, or add a suggested/custom key):") for i, key in enumerate(options, 1): mark = " *" if key in existing else "" print(f" {i:>2}) {key}{mark}") custom_num = len(options) + 1 print(f" {custom_num:>2}) Custom key") choice = input(f"\n Pick [1-{custom_num}] or type key name: ").strip() try: idx = int(choice) if idx == custom_num: key = input(" Key name: ").strip() elif 1 <= idx <= len(options): key = options[idx - 1] else: print(" Invalid number.") return except ValueError: key = choice # typed a key name directly if not key: print(" No key provided, aborting.") return typed = key canonical = canonical_for(key) if canonical: # Deliberately never claims "nothing reads this". Several of these names # ARE read, as explicit fallbacks — cloudflare.py reads # CLOUDFLARE_API_TOKEN, github.py reads GITHUB_TOKEN. Saying otherwise # is both false and, worse, tells someone their working setup is broken. print(f"\n ⚠️ '{key}' is not the name Core looks up first — that is '{canonical}'.") if canonical in v.list_keys(): # The dangerous case: both spellings hold a credential, and the one # you are about to edit is not the one tools prefer. print(f" '{canonical}' is ALSO in the vault, and tools read it first.") print(f" Updating only '{key}' would leave the value actually in use") print(" unchanged — the classic 'I fixed it and it is still broken'.") else: print(" Saving under the name you typed still works, but the canonical") print(" name is the one every tool agrees on.") if input(f" Use '{canonical}' instead? [Y/n]: ").strip().lower() not in ("n", "no"): key = canonical # Exact, not v.get(): lookups fall back to a normalised match, so v.get() # would report a differently-spelt key as "already exists" and then write a # second one beside it. if key in v.list_keys(): confirm = input(f" '{key}' already exists. Overwrite? [y/N]: ").strip().lower() if confirm != "y": print(" Skipped.") return value = input(f" Paste value for '{key}': ").strip() if not value: print(" Empty value, aborting.") return v.set(key, value) print(f" Saved '{key}'. Total secrets: {len(v.list_keys())}") # Renaming leaves the old spelling behind holding a stale credential, which # is worth clearing: two keys that differ only in spelling are exactly what # makes a lookup ambiguous later. if key != typed and typed in v.list_keys(): if input(f" Remove the old '{typed}'? [Y/n]: ").strip().lower() not in ("n", "no"): v.delete(typed) print(f" Removed '{typed}'. Total secrets: {len(v.list_keys())}") def cmd_delete(v): key = input(" Key to delete: ").strip() if not key: return if not v.get(key): print(f" '{key}' not found.") return confirm = input(f" Delete '{key}'? [y/N]: ").strip().lower() if confirm == "y": v.delete(key) print(f" Deleted. Total secrets: {len(v.list_keys())}") def cmd_check(v): key = input(" Key to check: ").strip() val = v.get(key) if not val: print(f" '{key}' = NOT FOUND") return exact = "" if key in v.list_keys() else " (resolved by name normalisation)" print(f" '{key}' = exists ({len(val)} chars){exact}") # "Exists" is not the same as "works" — say so here rather than let a tool # discover it later as a codec error. for fault in describe_value_faults(val): print(f" ⚠️ {fault}") def cmd_health(v): """Report keys that are misspelt, duplicated, or hold an unusable value. Exists because these failures are invisible until a tool dies far away from the vault: an unreadable name reports "not configured", a duplicate lets you update the copy nothing reads, and a value containing non-ASCII kills the HTTP request with a codec error that never mentions credentials. Values are never printed — only their shape. """ keys = sorted(v.list_keys()) problems = 0 groups: dict[str, list[str]] = {} for k in keys: groups.setdefault(_normalise_key(k), []).append(k) dupes = {n: ks for n, ks in groups.items() if len(ks) > 1} if dupes: print("\n Duplicate keys — these differ only in spelling:") for norm, ks in sorted(dupes.items()): problems += 1 print(f" {norm}: {', '.join(ks)}") print(" A lookup matching one exactly still works, but any lookup that") print(" matches none of them exactly is ambiguous and returns nothing.") print(" Worse, updating one leaves the others holding a stale credential.") print(" Keep one and delete the rest.") misnamed = [(k, canonical_for(k)) for k in keys] misnamed = [(k, c) for k, c in misnamed if c] if misnamed: print("\n Non-canonical names — Core looks up a different name first:") for k, c in misnamed: problems += 1 print(f" {k} -> canonical is {c}") print(" Some of these are read as explicit fallbacks and work today.") print(" They are worth renaming anyway: the fallback chains are the") print(" reason nobody can tell which name is the real one.") bad_values = [(k, describe_value_faults(v.get(k) or "")) for k in keys] bad_values = [(k, f) for k, f in bad_values if f] if bad_values: print("\n Malformed values — present and findable, but unusable:") for k, faults in bad_values: problems += 1 for fault in faults: print(f" {k}: {fault}") print(" HTTP headers are latin-1, so a non-ASCII character stops the") print(" request being built at all; stray whitespace usually surfaces") print(" as a 401 that reads like a permissions problem. Re-paste, or") print(" mint a fresh credential.") if problems: print(f"\n {problems} problem(s) across {len(keys)} secrets.\n") else: print(f"\n No problems found across {len(keys)} secrets.\n") def cmd_migrate_salt(v): salt_path = Path("config/secrets.salt") if salt_path.exists(): print(" Per-instance salt already exists — already migrated.") return print("\n This will migrate from the hardcoded vault salt to a random per-instance salt.") print(" The vault will be re-encrypted. Your passphrase is needed to re-derive the key.") confirm = input("\n Proceed? [y/N]: ").strip().lower() if confirm != "y": print(" Aborted.") return passphrase = KEY_FILE.read_text().strip() v.migrate_salt(passphrase) print(f" Salt migrated. New salt saved to {salt_path}") print(f" Vault re-encrypted with new key. Total secrets: {len(v.list_keys())}") # Module-level so tests can point detection away from the REAL host units: # on any live install these files exist and would beat every test fixture. SYSTEM_UNIT_PATHS = (Path("/etc/systemd/system/kbots.service"), Path("/etc/systemd/system/k-agents.service")) def detect_overlay() -> tuple[str, str] | None: """Find the overlay this host actually runs, and say where the answer came from. An unset KBOTS_OVERLAY does NOT mean "engine-local install". The variable is exported from a shell profile, so any shell opened before setup ran, and every non-interactive shell on Debian (the profile export sits below the interactive guard), sees it unset on a perfectly normal overlay install. Inferring engine-local from that sends secrets to a file the service never reads, and answering "y" at the old prompt did exactly that. So look at what is installed rather than at the environment. """ import os import re env = os.environ.get("KBOTS_OVERLAY") if env: return env, "KBOTS_OVERLAY" # The service unit is authoritative: it is what the running engine reads. for unit in (*SYSTEM_UNIT_PATHS, Path.home() / ".config/systemd/user/kbots.service"): try: m = re.search(r"^Environment=KBOTS_OVERLAY=(.+)$", unit.read_text(), re.M) except OSError: continue if m: return m.group(1).strip(), str(unit) plist = Path.home() / "Library/LaunchAgents/com.kbots.agent.plist" try: m = re.search(r"KBOTS_OVERLAY\s*([^<]+)", plist.read_text()) if m: return m.group(1).strip(), str(plist) except OSError: pass # No unit (a fresh install, or a user-run engine): the shell profile still # records the intent even when the current shell never sourced it. for profile in (Path.home() / ".bashrc", Path.home() / ".zshrc", Path.home() / ".profile"): try: m = re.search(r"^\s*export\s+KBOTS_OVERLAY=(.+)$", profile.read_text(), re.M) except OSError: continue if m: return m.group(1).strip().strip('"\''), f"~/{profile.name}" return None def main(): import os detected = detect_overlay() if detected and not os.environ.get("KBOTS_OVERLAY"): overlay, source = detected if (Path(overlay) / "config").is_dir(): os.environ["KBOTS_OVERLAY"] = overlay print("\n KBOTS_OVERLAY was unset in this shell — using the overlay this") print(f" host is installed with, found in {source}:") print(f" {overlay}") else: detected = None vault_path = resolve_config_file("secrets.enc") if not os.environ.get("KBOTS_OVERLAY"): print("\n ⚠️ No overlay install detected — falling back to the vault inside") print(f" the engine checkout: {vault_path}") print(" On an overlay-based install the service does NOT read this file,") print(" so secrets saved here will never reach the bots. Set KBOTS_OVERLAY") print(" to your overlay path and re-run unless this is intentional.") if input(" Continue anyway? [y/N]: ").strip().lower() != "y": return v = get_vault() print(f"\n Vault: {vault_path}") while True: print("\n kbots Vault Manager") print(" ─────────────────────") print(" 1) List secrets") print(" 2) Add/update secret") print(" 3) Check if secret exists") print(" 4) Delete secret") print(" 5) Check all keys and values for problems") print(" 6) Migrate vault salt") print(" q) Quit") try: choice = input("\n > ").strip().lower() except (KeyboardInterrupt, EOFError): print("\n Done.") break try: if choice in ("1", "list"): cmd_list(v) elif choice in ("2", "add"): cmd_add(v) elif choice in ("3", "check"): cmd_check(v) elif choice in ("4", "delete"): cmd_delete(v) elif choice in ("5", "health", "doctor"): cmd_health(v) elif choice in ("6", "migrate"): cmd_migrate_salt(v) elif choice in ("q", "quit", "exit"): print(" Done.") break else: print(" Invalid choice.") except (KeyboardInterrupt, EOFError): # Ctrl-C / Ctrl-D mid-operation → abandon it, back to the menu. print("\n Cancelled — back to menu.") if __name__ == "__main__": try: main() except (KeyboardInterrupt, EOFError): print("\n Done.")