181 lines
9.2 KiB
Bash
181 lines
9.2 KiB
Bash
#!/usr/bin/env bash
|
|
# vault-helper.sh — non-interactive safety rails on top of the canonical vault.sh.
|
|
#
|
|
# Why this exists: the base vault.sh `add`/`edit` flow is interactive ($EDITOR),
|
|
# which Claude Code cannot drive — so sessions improvise with raw `sops` and get
|
|
# it wrong (plaintext fields, wrong paths, forgotten encrypt). This wrapper does
|
|
# create / set / verify NON-interactively and refuses to leave a secret in
|
|
# plaintext. Reads delegate to the canonical vault.sh.
|
|
#
|
|
# Commands:
|
|
# new <path> --kind <k> [--name "..."] [--url "..."] [--tag T]... --set k=v [--set k=v]...
|
|
# Create a new ENCRYPTED entry in one shot.
|
|
# set <path> --set k=v [--set k=v]...
|
|
# Add/update credential field(s) on an existing entry.
|
|
# verify <path> Assert one entry is encrypted + round-trips.
|
|
# check [dir] Scan a dir (default whole vault) for any *.sops.yaml
|
|
# that is NOT encrypted (catches plaintext leaks).
|
|
# get <path> [field] Delegate to vault.sh get / get-field (read).
|
|
# find <query> Delegate to vault.sh search (read).
|
|
# list [dir] Delegate to vault.sh list (read).
|
|
# root Print the resolved vault root + how it was found.
|
|
#
|
|
# Secrets ALWAYS go under `credentials:` (the .sops.yaml encrypted_regex encrypts
|
|
# the keys: credentials|password|secret|api_key|token|pre_shared_key|notes|content).
|
|
# Anything you put OUTSIDE those keys is committed in PLAINTEXT — never do that.
|
|
set -euo pipefail
|
|
|
|
# ── Resolve vault root (the #1 thing sessions get wrong) ──────────────────────
|
|
# Order: $VAULT_PATH override → the repo we're standing in (correct identity) →
|
|
# $HOME identity vault_path → $HOME identity claudetools_root → that repo's identity.
|
|
resolve_vault() {
|
|
local how p root idf
|
|
if [[ -n "${VAULT_PATH:-}" && -d "${VAULT_PATH}" ]]; then echo "$VAULT_PATH|env:VAULT_PATH"; return 0; fi
|
|
if [[ -n "${VAULT_ROOT_ENV:-}" && -d "${VAULT_ROOT_ENV}" ]]; then echo "$VAULT_ROOT_ENV|env:VAULT_ROOT_ENV"; return 0; fi
|
|
# repo we're standing in (most reliable: uses the in-repo identity.json that has vault_path)
|
|
if root=$(git rev-parse --show-toplevel 2>/dev/null); then
|
|
idf="$root/.claude/identity.json"
|
|
if [[ -f "$idf" ]]; then
|
|
p=$(_id_field "$idf" vault_path); [[ -n "$p" && -d "$p" ]] && { echo "$p|repo-identity:$idf"; return 0; }
|
|
fi
|
|
fi
|
|
# home identity vault_path
|
|
idf="$HOME/.claude/identity.json"
|
|
if [[ -f "$idf" ]]; then
|
|
p=$(_id_field "$idf" vault_path); [[ -n "$p" && -d "$p" ]] && { echo "$p|home-identity"; return 0; }
|
|
# home identity -> claudetools_root -> repo identity vault_path
|
|
local ctr; ctr=$(_id_field "$idf" claudetools_root)
|
|
if [[ -n "$ctr" && -f "$ctr/.claude/identity.json" ]]; then
|
|
p=$(_id_field "$ctr/.claude/identity.json" vault_path); [[ -n "$p" && -d "$p" ]] && { echo "$p|home->repo-identity"; return 0; }
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
_id_field() { # <identity.json> <field>
|
|
local f="$1" k="$2" v=""
|
|
if command -v jq >/dev/null 2>&1; then v=$(jq -r --arg k "$k" '.[$k] // empty' "$f" 2>/dev/null); fi
|
|
if [[ -z "$v" ]]; then
|
|
local fp="$f"; command -v cygpath >/dev/null 2>&1 && fp=$(cygpath -m "$f")
|
|
local py; for py in py python3 python; do command -v "$py" >/dev/null 2>&1 && { v=$("$py" -c "import json;print(json.load(open(r'$fp')).get('$k',''))" 2>/dev/null) && break; }; done
|
|
fi
|
|
echo "$v"
|
|
}
|
|
|
|
VR_RAW=$(resolve_vault) || { echo "[ERROR] Could not resolve the vault root." >&2
|
|
echo " Set \$VAULT_PATH, or add vault_path to .claude/identity.json, or run from inside the ClaudeTools repo." >&2; exit 3; }
|
|
VAULT_DIR="${VR_RAW%%|*}"
|
|
VAULT_HOW="${VR_RAW##*|}"
|
|
|
|
VAULT_SH="$VAULT_DIR/scripts/vault.sh" # canonical read tool (delegate to it)
|
|
|
|
_py() { local p; for p in py python3 python; do command -v "$p" >/dev/null 2>&1 && { echo "$p"; return 0; }; done; return 1; }
|
|
PY=$(_py) || { echo "[ERROR] python (py/python3/python) required" >&2; exit 3; }
|
|
|
|
abspath() { # path arg -> absolute vault file (.sops.yaml appended if missing)
|
|
local p="$1"; [[ "$p" == *.sops.yaml ]] || p="$p.sops.yaml"
|
|
echo "$VAULT_DIR/$p"
|
|
}
|
|
|
|
# assert a file on disk is encrypted (has ENC[ blobs) and round-trips
|
|
_is_encrypted() { grep -q 'ENC\[AES256_GCM' "$1" 2>/dev/null; }
|
|
|
|
cmd_verify() {
|
|
local f; f=$(abspath "${1:?usage: verify <path>}")
|
|
[[ -f "$f" ]] || { echo "[ERROR] not found: $f" >&2; exit 1; }
|
|
if ! _is_encrypted "$f"; then echo "[FAIL] $1 — NO encrypted values found (plaintext?)"; exit 1; fi
|
|
if ! ( cd "$VAULT_DIR" && sops -d "$f" >/dev/null 2>&1 ); then echo "[FAIL] $1 — encrypted but does not decrypt (key mismatch?)"; exit 1; fi
|
|
echo "[OK] $1 — encrypted and decrypts cleanly"
|
|
}
|
|
|
|
cmd_check() {
|
|
local dir="${1:-}"; local scan="$VAULT_DIR"; [[ -n "$dir" ]] && scan="$VAULT_DIR/$dir"
|
|
local bad=0 total=0 f
|
|
while IFS= read -r -d '' f; do
|
|
total=$((total+1))
|
|
if ! _is_encrypted "$f"; then echo "[PLAINTEXT] ${f#$VAULT_DIR/}"; bad=$((bad+1)); fi
|
|
done < <(find "$scan" -name '*.sops.yaml' -type f -print0 2>/dev/null)
|
|
if [[ $bad -eq 0 ]]; then echo "[OK] $total entr(ies) scanned — all encrypted"; else
|
|
echo "[CRITICAL] $bad of $total *.sops.yaml file(s) are NOT encrypted — fix before committing"; exit 1; fi
|
|
}
|
|
|
|
cmd_new() {
|
|
local path="" kind="generic" name="" url="" tags=() sets=()
|
|
path="${1:?usage: new <path> --kind <k> --set k=v ...}"; shift
|
|
while [[ $# -gt 0 ]]; do case "$1" in
|
|
--kind) kind="$2"; shift 2;; --name) name="$2"; shift 2;; --url) url="$2"; shift 2;;
|
|
--tag) tags+=("$2"); shift 2;; --set) sets+=("$2"); shift 2;;
|
|
*) echo "[ERROR] unknown arg: $1" >&2; exit 64;; esac; done
|
|
[[ ${#sets[@]} -gt 0 ]] || { echo "[ERROR] need at least one --set key=value (the secret)" >&2; exit 64; }
|
|
local f; f=$(abspath "$path")
|
|
[[ -e "$f" ]] && { echo "[ERROR] already exists: ${f#$VAULT_DIR/} (use 'set' to update)" >&2; exit 1; }
|
|
mkdir -p "$(dirname "$f")"
|
|
# Build plaintext YAML with python (safe quoting); secrets go under credentials:
|
|
KIND="$kind" NAME="$name" URL="$url" TAGS="$(printf '%s\n' "${tags[@]:-}")" SETS="$(printf '%s\n' "${sets[@]}")" \
|
|
"$PY" - "$f" <<'PY'
|
|
import os, sys, yaml
|
|
f=sys.argv[1]
|
|
doc={"kind":os.environ["KIND"],"name":os.environ.get("NAME","") or ""}
|
|
if os.environ.get("URL"): doc["url"]=os.environ["URL"]
|
|
doc["status"]="active"
|
|
tags=[t for t in os.environ.get("TAGS","").splitlines() if t.strip()]
|
|
if tags: doc["tags"]=tags
|
|
creds={}
|
|
for kv in os.environ.get("SETS","").splitlines():
|
|
if not kv.strip(): continue
|
|
k,_,v=kv.partition("="); creds[k.strip()]=v
|
|
doc["credentials"]=creds
|
|
doc["notes"]=""
|
|
with open(f,"w",encoding="utf-8",newline="\n") as fh:
|
|
yaml.safe_dump(doc,fh,default_flow_style=False,sort_keys=False,allow_unicode=True)
|
|
PY
|
|
( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] sops encrypt failed; removing plaintext" >&2; rm -f "$f"; exit 1; }
|
|
cmd_verify "$path"
|
|
echo "[INFO] Created ${f#$VAULT_DIR/}. Publish with: bash .claude/scripts/sync.sh (Phase 6 commits+pushes the vault)"
|
|
}
|
|
|
|
cmd_set() {
|
|
local path="" sets=()
|
|
path="${1:?usage: set <path> --set k=v ...}"; shift
|
|
while [[ $# -gt 0 ]]; do case "$1" in --set) sets+=("$2"); shift 2;; *) echo "[ERROR] unknown arg: $1" >&2; exit 64;; esac; done
|
|
[[ ${#sets[@]} -gt 0 ]] || { echo "[ERROR] need at least one --set key=value" >&2; exit 64; }
|
|
local f; f=$(abspath "$path")
|
|
[[ -f "$f" ]] || { echo "[ERROR] not found: ${f#$VAULT_DIR/} (use 'new' to create)" >&2; exit 1; }
|
|
local tmp; tmp=$(mktemp)
|
|
( cd "$VAULT_DIR" && sops -d "$f" ) > "$tmp" 2>/dev/null || { echo "[ERROR] decrypt failed" >&2; rm -f "$tmp"; exit 1; }
|
|
SETS="$(printf '%s\n' "${sets[@]}")" "$PY" - "$tmp" <<'PY'
|
|
import os,sys,yaml
|
|
f=sys.argv[1]
|
|
doc=yaml.safe_load(open(f,encoding="utf-8")) or {}
|
|
doc.setdefault("credentials",{})
|
|
for kv in os.environ["SETS"].splitlines():
|
|
if not kv.strip(): continue
|
|
k,_,v=kv.partition("="); doc["credentials"][k.strip()]=v
|
|
yaml.safe_dump(doc,open(f,"w",encoding="utf-8",newline="\n"),default_flow_style=False,sort_keys=False,allow_unicode=True)
|
|
PY
|
|
cp "$tmp" "$f"; rm -f "$tmp"
|
|
( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] re-encrypt failed" >&2; exit 1; }
|
|
cmd_verify "$path"
|
|
echo "[INFO] Updated ${f#$VAULT_DIR/}. Publish with: bash .claude/scripts/sync.sh"
|
|
}
|
|
|
|
case "${1:-}" in
|
|
new) shift; cmd_new "$@" ;;
|
|
set) shift; cmd_set "$@" ;;
|
|
verify) shift; cmd_verify "$@" ;;
|
|
check) shift; cmd_check "$@" ;;
|
|
get) shift; if [[ -n "${2:-}" ]]; then bash "$VAULT_SH" get-field "$1" "$2"; else bash "$VAULT_SH" get "$1"; fi ;;
|
|
find) shift; bash "$VAULT_SH" search "$@" ;;
|
|
list) shift; bash "$VAULT_SH" list "$@" ;;
|
|
root) echo "vault root: $VAULT_DIR"; echo "resolved via: $VAULT_HOW" ;;
|
|
*) cat >&2 <<EOF
|
|
vault-helper.sh — non-interactive vault ops (rails on top of vault.sh)
|
|
new <path> --kind <k> [--name ..] [--url ..] [--tag T].. --set k=v [--set k=v]..
|
|
set <path> --set k=v [--set k=v]..
|
|
verify <path> check [dir]
|
|
get <path> [field] find <query> list [dir] root
|
|
Secrets ALWAYS go under credentials:. Publish changes with: bash .claude/scripts/sync.sh
|
|
EOF
|
|
exit 64 ;;
|
|
esac
|