From 2f351638662515cf85f57573033c316a4ff3cd8a Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Fri, 10 Jul 2026 07:56:25 -0700 Subject: [PATCH] harness: read Syncro keys from vault, stop hardcoding the repo root Two defects found while running /wiki-compile, both from tooling assuming a path instead of resolving it. 1. Plaintext Syncro API keys. Mike's and Howard's live PSA keys were copy-pasted into three command files, a script, and two catalog docs -- despite both already being vaulted at msp-tools/syncro and msp-tools/syncro-howard. Replaced with reads from the SOPS vault via a new sourced helper, .claude/scripts/syncro-env.sh. Write paths fail closed; read-only paths degrade to skipped enrichment rather than a wrong key. Per-user mapping is unchanged, so Syncro attribution is too. 2. Hardcoded repo root. wiki-compile/wiki-lint/inject-standards and gen_b64.py hardcoded D:/claudetools; this machine is C:/claudetools. syncro.md also read ~/.claude/identity.json before the repo copy -- the same bug that made remediation-tool's consent-audit report a fully consented tenant as RED. Root now resolves from the script's own location, with identity.json claudetools_root as the override. get-identity.sh had a chicken-and-egg bug: it read ${CLAUDETOOLS_ROOT:-.} but never set it, so every caller had to already know the root. It now self-resolves and exports CLAUDETOOLS_ROOT + VAULT_ROOT. Verified: all three command setup blocks authenticate against live Syncro; get-identity.sh works from any cwd and honors a pre-set root; gps-rmm autoenroll resolves its key from the vault. security-review: no findings. NOTE: both keys remain valid in git history. Rotation in the Syncro portal is the required follow-up -- this commit does not resolve that exposure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/inject-standards.md | 8 ++-- .claude/commands/syncro.md | 51 +++++++++--------------- .claude/commands/wiki-compile.md | 21 +++++----- .claude/commands/wiki-lint.md | 21 ++++++---- .claude/scripts/gen_b64.py | 6 ++- .claude/scripts/get-identity.sh | 15 ++++++- .claude/scripts/gps-rmm-autoenroll.sh | 3 +- .claude/scripts/syncro-env.sh | 57 +++++++++++++++++++++++++++ CATALOG_CLIENTS.md | 2 +- CATALOG_SHARED_DATA.md | 2 +- 10 files changed, 125 insertions(+), 61 deletions(-) create mode 100644 .claude/scripts/syncro-env.sh diff --git a/.claude/commands/inject-standards.md b/.claude/commands/inject-standards.md index f88e4de4..de3f1c13 100644 --- a/.claude/commands/inject-standards.md +++ b/.claude/commands/inject-standards.md @@ -23,7 +23,7 @@ Follow these steps exactly when /inject-standards is invoked: ### Step 2 — Auto-select relevant standards -1. Read `D:/claudetools/.claude/standards/index.yml`. +1. Read `.claude/standards/index.yml`. 2. Review the descriptions for all entries. 3. Select the 2–5 standards most relevant to either: - The task description in $ARGUMENTS, or @@ -35,7 +35,7 @@ Follow these steps exactly when /inject-standards is invoked: For each selected standard (in order of relevance): -1. Read the file at `D:/claudetools/.claude/standards/.md`. +1. Read the file at `.claude/standards/.md`. 2. Display a header: ``` === STANDARD: === @@ -87,8 +87,8 @@ Reads the recent conversation, infers the task type, selects 2–5 most relevant ## Standards index location -`D:/claudetools/.claude/standards/index.yml` +`.claude/standards/index.yml` ## Standards files location -`D:/claudetools/.claude/standards//.md` +`.claude/standards//.md` diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index 8ada3b4d..fbeff45a 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -106,43 +106,28 @@ Keys are baked into the skill below. To add a new user: generate a token in Sync ### Get API key ```bash -BASE="https://computerguru.syncromsp.com/api/v1" +# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's +# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key. +# +# The key is per-user on purpose: every action in Syncro is attributed to the key +# owner, so using someone else's key misattributes tickets, time, and invoices. +# +# NOTE: identity.json lives in the REPO (.claude/identity.json), not ~/.claude/. +# Reading ~/.claude/identity.json first is a known bug — it silently picks up a +# stale or absent file. syncro-env.sh resolves the repo copy from its own location. +source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || { + echo "[ERROR] Cannot resolve Syncro credentials — run onboarding / check the vault" >&2 + exit 1 +} -# Get repo root from identity.json (set during machine onboarding) -# Fallback to dynamic detection for legacy machines that haven't updated identity.json yet -IDENTITY_PATH="${HOME}/.claude/identity.json" -if [ ! -f "$IDENTITY_PATH" ]; then - # Try in-repo identity.json (gitignored, machine-specific) - REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) - if [ -n "$REPO_ROOT" ]; then - IDENTITY_PATH="$REPO_ROOT/.claude/identity.json" - fi -fi +BASE="$SYNCRO_BASE" +API_KEY="$SYNCRO_API_KEY" +REPO_ROOT="$CLAUDETOOLS_ROOT" -if [ ! -f "$IDENTITY_PATH" ]; then - echo "[ERROR] Cannot locate identity.json - run onboarding first" >&2 +if [ -z "$API_KEY" ]; then + echo "[ERROR] No Syncro API key for user '${SYNCRO_USER:-unknown}'" >&2 exit 1 fi - -REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH") -if [ -z "$REPO_ROOT" ]; then - # Legacy fallback for machines without claudetools_root in identity.json - REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) - if [ -z "$REPO_ROOT" ]; then - echo "[ERROR] claudetools_root not set in identity.json and not in a git directory" >&2 - echo "[ERROR] Add 'claudetools_root' field to $IDENTITY_PATH" >&2 - exit 1 - fi - echo "[WARNING] Using git-detected repo root. Add 'claudetools_root' to identity.json to avoid this." >&2 -fi - -# Per-user keys — actions in Syncro are attributed to the key owner -USER_ID=$(jq -r '.user // empty' "$IDENTITY_PATH") -case "$USER_ID" in - mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;; - howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;; - *) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;; -esac ``` ### Ollama drafting diff --git a/.claude/commands/wiki-compile.md b/.claude/commands/wiki-compile.md index d4ba97da..d9374860 100644 --- a/.claude/commands/wiki-compile.md +++ b/.claude/commands/wiki-compile.md @@ -36,17 +36,18 @@ want a periodic clean rebuild. For "I just did some work, capture it," use plain ## Phase 0 — Setup ```bash -CLAUDETOOLS_ROOT="D:/claudetools" -VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" +# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's +# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key. +# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY. +source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true -# Syncro auth (read-only operations only — GET requests ONLY in this skill) -BASE="https://computerguru.syncromsp.com/api/v1" -USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json") -case "$USER_ID" in - mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;; - howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;; - *) echo "[WARNING] Unknown user — Syncro enrichment skipped" ; API_KEY="" ;; -esac +VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" +BASE="$SYNCRO_BASE" # read-only: GET requests ONLY in this skill +API_KEY="$SYNCRO_API_KEY" # empty if no key vaulted for this user -> skip enrichment + +if [ -z "$API_KEY" ]; then + echo "[WARNING] No Syncro key for user '${SYNCRO_USER:-unknown}' — Syncro enrichment skipped" +fi ``` **Synthesis engine:** seed/full article drafting is done by a **Sonnet subagent** (Agent tool, `model: "sonnet"`), not Ollama. The main agent gathers sources + Syncro data, delegates the draft, then reviews it before writing. No Ollama dependency. diff --git a/.claude/commands/wiki-lint.md b/.claude/commands/wiki-lint.md index bf799840..e9be6ab2 100644 --- a/.claude/commands/wiki-lint.md +++ b/.claude/commands/wiki-lint.md @@ -10,7 +10,8 @@ Scan for clients and projects that have session logs but no wiki article. ```bash # List all client slugs that have session-logs but no wiki article -cd D:/claudetools +# Repo root is machine-specific (C:/ on some, D:/ on others) — never hardcode it. +cd "$(git rev-parse --show-toplevel)" for dir in clients/*/session-logs; do slug=$(echo "$dir" | sed 's|clients/||;s|/session-logs||') wiki="wiki/clients/$slug.md" @@ -96,13 +97,17 @@ For every client wiki article that contains a `Syncro customer ID` line, pull li ### Setup ```bash -BASE="https://computerguru.syncromsp.com/api/v1" -USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json") -case "$USER_ID" in - mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;; - howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;; - *) echo "[SYNCRO] No API key for user '$USER_ID' — skipping Step 6" ; exit 0 ;; -esac +# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's +# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key. +source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true + +BASE="$SYNCRO_BASE" +API_KEY="$SYNCRO_API_KEY" + +if [ -z "$API_KEY" ]; then + echo "[SYNCRO] No API key for user '${SYNCRO_USER:-unknown}' — skipping Step 6" + exit 0 +fi ``` ### For Each Client Article diff --git a/.claude/scripts/gen_b64.py b/.claude/scripts/gen_b64.py index d17b7ac2..5d3a61ea 100644 --- a/.claude/scripts/gen_b64.py +++ b/.claude/scripts/gen_b64.py @@ -1,5 +1,5 @@ """Generate base64 of the WatchdogAlertsSection component.""" -import base64, sys +import base64, os, sys BT = chr(96) BULLET = "\xb7" @@ -195,7 +195,9 @@ lines = [ component = "".join(lines) b64 = base64.b64encode(component.encode("utf-8")).decode("ascii") -with open("D:/claudetools/.claude/scripts/component.b64", "w") as f: +# Write next to this script — the repo root is machine-specific, never hardcode it. +out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "component.b64") +with open(out_path, "w") as f: f.write(b64) print(f"Component: {len(component)} chars") diff --git a/.claude/scripts/get-identity.sh b/.claude/scripts/get-identity.sh index a63f4cb8..4f64cd70 100644 --- a/.claude/scripts/get-identity.sh +++ b/.claude/scripts/get-identity.sh @@ -12,7 +12,14 @@ # (but does NOT exit, so the caller continues). This ensures skills never break on # missing identity - they just attribute to "Unknown". -IDENTITY_FILE="${CLAUDETOOLS_ROOT:-.}/.claude/identity.json" +# Resolve the repo root from this script's own location (mirrors vault.sh) so callers +# don't have to know it. An already-set CLAUDETOOLS_ROOT still wins, and identity.json's +# claudetools_root overrides both. Never hardcode a drive letter. +if [ -z "${CLAUDETOOLS_ROOT:-}" ]; then + export CLAUDETOOLS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi + +IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json" if [ ! -f "$IDENTITY_FILE" ]; then echo "[WARNING] identity.json not found at $IDENTITY_FILE - attribution will be 'Unknown'" >&2 @@ -28,5 +35,11 @@ export USER_SHORT=$(jq -r '.user // "unknown"' "$IDENTITY_FILE" 2>/dev/null || e export MACHINE=$(jq -r '.machine // "unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown") export USER_EMAIL=$(jq -r '.email // "unknown@unknown.com"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown@unknown.com") +# identity.json is authoritative for the root and the vault location. +_root_override=$(jq -r '.claudetools_root // empty' "$IDENTITY_FILE" 2>/dev/null) +[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override" +export VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null) +unset _root_override + # Success return 0 diff --git a/.claude/scripts/gps-rmm-autoenroll.sh b/.claude/scripts/gps-rmm-autoenroll.sh index 951c2c2f..7bcc7c61 100644 --- a/.claude/scripts/gps-rmm-autoenroll.sh +++ b/.claude/scripts/gps-rmm-autoenroll.sh @@ -13,7 +13,8 @@ TS="$(date '+%Y-%m-%d %H:%M')" eval "$(bash .claude/scripts/rmm-auth.sh 2>/dev/null)" >/dev/null 2>&1 if [ -z "${TOKEN:-}" ]; then echo "$TS auth FAILED" >> "$LOG"; exit 0; fi SEC="$(bash .claude/scripts/vault.sh get-field msp-tools/screenconnect.sops.yaml credentials.api_secret 2>/dev/null | tr -d '\r\n')" -SK="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" +SK="$(bash .claude/scripts/vault.sh get-field msp-tools/syncro-howard credentials.credential 2>/dev/null | tr -d '\r\n')" +if [ -z "$SK" ]; then echo "$TS syncro key read FAILED" >> "$LOG"; exit 0; fi OUT="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" SC_SECRET="$SEC" python projects/gps-rmm-audit/tools/rebuild-and-push.py 2>/dev/null | tail -2)" PUSHED="$(echo "$OUT" | grep -oE 'pushed: [0-9]+' | grep -oE '[0-9]+' | head -1 || true)" diff --git a/.claude/scripts/syncro-env.sh b/.claude/scripts/syncro-env.sh new file mode 100644 index 00000000..d0afee79 --- /dev/null +++ b/.claude/scripts/syncro-env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# syncro-env.sh — Resolve the repo root and the caller's Syncro API key from the SOPS vault. +# +# Source this instead of hardcoding a repo path or an API key: +# source "$(dirname "${BASH_SOURCE[0]}")/syncro-env.sh" # or by absolute path +# curl -s "$SYNCRO_BASE/customers?api_key=$SYNCRO_API_KEY" +# +# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY +# +# Root resolution mirrors vault.sh: derive from this script's own location, then let +# identity.json's claudetools_root override. Never hardcode a drive letter — the repo +# lives at C:/claudetools on some machines and D:/claudetools on others. +# +# Soft-fails like get-identity.sh: on any failure it warns, leaves SYNCRO_API_KEY empty, +# and returns 1 without exiting, so a sourcing skill degrades instead of dying. + +_syncro_env_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export CLAUDETOOLS_ROOT="$(cd "$_syncro_env_dir/../.." && pwd)" +_identity="$CLAUDETOOLS_ROOT/.claude/identity.json" + +export SYNCRO_BASE="https://computerguru.syncromsp.com/api/v1" +export SYNCRO_API_KEY="" +export SYNCRO_USER="" +export VAULT_ROOT="" + +if [ ! -f "$_identity" ]; then + echo "[WARNING] identity.json not found at $_identity — Syncro calls will be unauthenticated" >&2 + return 1 2>/dev/null || exit 1 +fi + +# identity.json is authoritative for both the repo root and the vault location. +_root_override=$(jq -r '.claudetools_root // empty' "$_identity" 2>/dev/null) +[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override" +export VAULT_ROOT=$(jq -r '.vault_path // empty' "$_identity" 2>/dev/null) +export SYNCRO_USER=$(jq -r '.user // empty' "$_identity" 2>/dev/null) + +# Per-user token — attribution in Syncro depends on using the right one. +case "$SYNCRO_USER" in + mike) _vault_path="msp-tools/syncro" ;; + howard) _vault_path="msp-tools/syncro-howard" ;; + *) echo "[WARNING] No Syncro key vaulted for user '$SYNCRO_USER' — enrichment skipped" >&2 + return 1 2>/dev/null || exit 1 ;; +esac + +SYNCRO_API_KEY=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \ + get-field "$_vault_path" credentials.credential 2>/dev/null | tail -1) +export SYNCRO_API_KEY + +if [ -z "$SYNCRO_API_KEY" ]; then + echo "[ERROR] Could not read $_vault_path from vault — is SOPS/age configured?" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "syncro-env" \ + "vault read failed for $_vault_path" --context "user=$SYNCRO_USER" >/dev/null 2>&1 || true + return 1 2>/dev/null || exit 1 +fi + +unset _syncro_env_dir _identity _root_override _vault_path +return 0 2>/dev/null || true diff --git a/CATALOG_CLIENTS.md b/CATALOG_CLIENTS.md index b69477a0..46eb2832 100644 --- a/CATALOG_CLIENTS.md +++ b/CATALOG_CLIENTS.md @@ -93,7 +93,7 @@ #### Syncro PSA/RMM - **API Base:** https://computerguru.syncromsp.com/api/v1 -- **API Key:** T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3 +- **API Key:** vault `msp-tools/syncro` (field `credentials.credential`). Per-user key for Howard: `msp-tools/syncro-howard`. Read via `.claude/scripts/syncro-env.sh` — never hardcode. - **Subdomain:** computerguru - **Customers:** 5,064 (29 duplicates found) diff --git a/CATALOG_SHARED_DATA.md b/CATALOG_SHARED_DATA.md index d79f08e8..e99d0421 100644 --- a/CATALOG_SHARED_DATA.md +++ b/CATALOG_SHARED_DATA.md @@ -406,7 +406,7 @@ ### Syncro (PSA/RMM) - AZ Computer Guru - **Service:** PSA/RMM platform -- **API Key:** T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3 +- **API Key:** vault `msp-tools/syncro` (field `credentials.credential`). Per-user key for Howard: `msp-tools/syncro-howard`. Read via `.claude/scripts/syncro-env.sh` — never hardcode. - **Subdomain:** computerguru - **API Base URL:** https://computerguru.syncromsp.com/api/v1 - **API Docs:** https://api-docs.syncromsp.com/