From d1e41f0bb8ff002936845fb4ddd1bdcbd1b6a5bb Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Mon, 20 Jul 2026 17:04:09 -0700 Subject: [PATCH] add GPS backup audit aggregator + enforce skill-first routing GPS audit: new gps-audit.py queries MSP360/Seafile/ownCloud in one pass, produces unified per-client backup report with OK/REVIEW/FAIL verdicts. Supports --json, --client, --failed, --skip-owncloud flags. Skill routing: added /mailbox and 11 missing skills to SKILL_ROUTING.md, added concrete "NEVER hand-roll these" API endpoint block list to CLAUDE.md to prevent bypassing skills with raw curl calls. Updated skill-first memory with specific bypass patterns (Graph API, UniFi, Discord). Tracker: closed #19 (datto-edr), #20 (screenconnect) as validated, filed #88 (tailscale, closed/skipped), #89 (skill-bypass enforcement). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 12 + .claude/SKILL_ROUTING.md | 12 +- .../memory/feedback_skill_first_routing.md | 8 +- errorlog.md | 4 + projects/gps-rmm-audit/tools/gps-audit.py | 370 ++++++++++++++++++ 5 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 projects/gps-rmm-audit/tools/gps-audit.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 438ce76d..585242f5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -50,6 +50,18 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`. the memory rules (e.g. [[feedback_syncro_billing]]) describe what the SKILL does, not a license to free-hand it. Reach for raw API ONLY when no skill fits or the skill genuinely cannot do it — and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`). + **NEVER hand-roll these (hard blocks — skill exists, use it):** + `graph.microsoft.com` / EXO REST → `/mailbox` (read/send) or `remediation-tool` (audit) | + UniFi controller API / SSH to UDM/AP → `unifi-wifi` | + `api.mspbackups.com` → `msp360` | `workplace.datto.com` → `datto-workplace` | + `infocyte.com` → `datto-edr` | `gravityzone` JSON-RPC → `bitdefender` | + Syncro API → `/syncro` | `screenconnect.com` → `screenconnect` | + Seafile API → `seafile` | ownCloud occ → `owncloud` | Tailscale API → `tailscale` | + `api.backblazeb2.com` → `b2` | NetSapiens API → `packetdial` | YMCS API → `yealink-ymcs` | + Mailprotector API → `mailprotector` | SOPS/age decrypt → `vault` | + Discord API / webhook → `discord-dm` or `ask-forum` | Gitea API → `bug-tracker` or `gitea`. + Before writing `curl` to ANY of these endpoints, **STOP and use the skill**. The full + routing table is `.claude/SKILL_ROUTING.md` — read it when unsure. - **Definition of Done — route the REQUEST to a doing-skill, then GATE the result with the matching check-skill(s) before calling work done — automatically, without being asked.** You map the request to skills from the conversation + expected end result; the user should not diff --git a/.claude/SKILL_ROUTING.md b/.claude/SKILL_ROUTING.md index b3603ed0..9b079932 100644 --- a/.claude/SKILL_ROUTING.md +++ b/.claude/SKILL_ROUTING.md @@ -22,6 +22,7 @@ | Any credential — read/store/rotate a secret, API key, password, token, SSH key | `vault` | | Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) | | M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` | +| Read/send email, check inbox, unread messages, reply to an email | `/mailbox` (NOT hand-rolled Graph API) | | Onboard a new M365 tenant to the remediation app suite | `onboard365` | | Anything about a customer's **BACKUP** — is X backing up, who backs up where, backup status/failures, or set up / provision a backup | **Backups → wiki-first** (see the "Backups" block right below this table) | | Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` | @@ -31,7 +32,7 @@ | ScreenConnect / CW Control sessions, access installer, backstage command | `screenconnect` | | Synology NAS | `synology` | | Yealink phone device management | `yealink-ymcs` | -| UniFi WiFi tuning / RF / channel analysis | `unifi-wifi` | +| UniFi WiFi tuning / RF / channel analysis / AP config / 2.4GHz congestion / sticky clients / interference | `unifi-wifi` (NOT hand-rolled UniFi API calls or SSH) | | Map/repoint a Windows network drive on a remote endpoint | `drive-map` | | Send someone a Discord DM / copy-paste-friendly link or command | `discord-dm` | | Ask a teammate a question/decision/sign-off and get their human answer back in-session | `ask-forum` (run the `--wait` as a proper background task — NO shell `&`) | @@ -45,6 +46,15 @@ | Independent 2nd opinion / adversarial verify | `grok` (xAI) or `agy` (Gemini) | | Image/video gen, live web/X search past cutoff | `grok` | | Deep multi-source researched report | `deep-research` | +| ALIS staff import / Medtelligent roster for Cascades | `alis` | +| Tailscale tailnet — devices, auth keys, delete/authorize | `tailscale` | +| 1Password — store/read secrets, secret references, op:// | `1password` | +| Brainstorm / shape an idea / design something before building | `brainstorming` (explicit request only) | +| Lint/analyze the error log, find patterns | `errorlog-dream` | +| Post a technical article to the community forum | `forum-post` | +| Frontend UI design / web components / pages / dashboards | `frontend-design` | +| GuruConnect codebase audit | `gc-audit` | +| Trace an error to its root cause | `root-cause-tracing` | > Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`. diff --git a/.claude/memory/feedback_skill_first_routing.md b/.claude/memory/feedback_skill_first_routing.md index c3f87f31..ea10ee8c 100644 --- a/.claude/memory/feedback_skill_first_routing.md +++ b/.claude/memory/feedback_skill_first_routing.md @@ -13,8 +13,14 @@ When a request maps to an installed skill or slash-command, **invoke that skill* **How to apply:** - Billing/invoicing/ticketing/scheduling in Syncro -> `/syncro` (after-hours/emergency -> `/syncro-emergency-billing`). No exceptions, even for a "quick" one-line charge. -- More generally: before reaching for raw API, ask "is there a skill for this?" Credentials -> `vault`; RMM actions -> `/rmm` (find the host with `rmm-search`); M365 investigation/remediation -> `remediation-tool`; the per-vendor skills (bitdefender, datto-edr, packetdial, b2, mailprotector, screenconnect...) own their APIs. +- More generally: before reaching for raw API, ask "is there a skill for this?" The CLAUDE.md "NEVER hand-roll these" block lists every covered API endpoint. Key ones that get bypassed in practice: + - `graph.microsoft.com` / EXO REST -> `/mailbox` (read/send) or `remediation-tool` (audit) + - UniFi controller API / SSH to UDM/AP -> `unifi-wifi` (NOT raw curl or SSH) + - `api.mspbackups.com` -> `msp360` + - Discord API/webhook -> `discord-dm` or `ask-forum` (never hand-roll) + - Credentials -> `vault`; RMM actions -> `/rmm` (find host with `rmm-search`); per-vendor skills (bitdefender, datto-edr, packetdial, b2, mailprotector, screenconnect, seafile, owncloud, tailscale, yealink-ymcs) own their APIs. - Use raw API ONLY when no skill fits, or the skill genuinely cannot do the thing — and SAY SO explicitly when you do, so the user can sanity-check. +- **Practical check:** before writing `curl `, does the URL domain appear in the CLAUDE.md block list? If yes, STOP and use the skill. - When the user corrects a bypass, log it: `bash .claude/scripts/log-skill-error.sh "" "hand-rolled API instead of using the skill" --correction`. Related: [[feedback_psa_default_syncro]] (Syncro is the default PSA), [[feedback_syncro_preview_mandatory]] (preview gate the skill enforces), [[feedback_syncro_priority_type_format]] (a malformed-ticket case Winter flagged), [[feedback_syncro_billing]], [[feedback_syncro_workflow]]. diff --git a/errorlog.md b/errorlog.md index 4b5e9a71..d3395bbb 100644 --- a/errorlog.md +++ b/errorlog.md @@ -19,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-07-20 | Howard-Home | harness/skill-routing | [correction] Skills being bypassed -- hand-rolling API calls when skills exist (email/mailbox, unifi, others). CLAUDE.md skill-first rule not sufficient as a guardrail. [ctx: ref=CLAUDE.md-skill-first-rule] + +2026-07-20 | Howard-Home | gps-audit | skill call failed: C:claudetools.claudeskillsowncloudscriptsowncloud.py ['usage', '--json'] -- Command '['bash', 'C:claudetools.claudescriptspy.sh', 'C:claudetools.claudeskillsowncloudscriptsowncloud.py', 'usage', '--json']' timed out after 60 seconds + 2026-07-20 | Howard-Home | tailscale | Skill blocked: no vault entry at tailscale/api-access.sops.yaml -- need OAuth client_id+client_secret or API key provisioned [ctx: ref=skill-testing-2026-07-20] 2026-07-20 | Howard-Home | bash/parallel-tools | [friction] Ran Skill call parallel with a Bash call that errored -- system cancelled the Skill. Must not pair fragile Bash calls with Skill calls in the same parallel block. [ctx: ref=parallel-tool-cancel] diff --git a/projects/gps-rmm-audit/tools/gps-audit.py b/projects/gps-rmm-audit/tools/gps-audit.py new file mode 100644 index 00000000..e09c0c7e --- /dev/null +++ b/projects/gps-rmm-audit/tools/gps-audit.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""GPS backup audit aggregator -- queries all backup backends and produces a +unified per-client backup status report. + +Backends: + - MSP360 MBS (monitoring API) -- the primary "is it backing up" source + - Seafile Pro (usage API) + - ownCloud (usage via SSH/occ) + - Datto Workplace (RMM endpoint detection -- API is blind) + +Usage: + py gps-audit.py # full report, all backends + py gps-audit.py --json # JSON output + py gps-audit.py --client "Dataforth" # single client + py gps-audit.py --failed # only FAIL/REVIEW clients + py gps-audit.py --skip-owncloud # skip slow SSH-based ownCloud check + py gps-audit.py --skip-datto # skip Datto Workplace (needs RMM probe) +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parent.parent.parent # tools -> gps-rmm-audit -> projects -> repo... no +# Correct: tools/ is inside projects/gps-rmm-audit/tools/ +# repo root is 3 levels up from tools/ +_THIS = Path(__file__).resolve() +REPO = _THIS.parent.parent.parent.parent # tools -> gps-rmm-audit -> projects -> repo + +PY_SH = str(REPO / ".claude" / "scripts" / "py.sh") +MSP360_CLI = str(REPO / ".claude" / "skills" / "msp360" / "scripts" / "msp360.py") +SEAFILE_CLI = str(REPO / ".claude" / "skills" / "seafile" / "scripts" / "seafile.py") +OWNCLOUD_CLI = str(REPO / ".claude" / "skills" / "owncloud" / "scripts" / "owncloud.py") +LOGERR = str(REPO / ".claude" / "scripts" / "log-skill-error.sh") + +# Client -> backend mapping (from backup-map.md, manually maintained) +# Only clients that are BILLED for backup or have known backup activity +CLIENT_BACKENDS: dict[str, list[str]] = { + "Dataforth": ["msp360"], + "Valley Wide Plastering": ["msp360"], + "Curtis Plumbing": ["msp360"], + "Ridgetop Group": ["msp360"], + "Brett Interiors": ["msp360"], + "Gary A Hartman LLC": ["msp360"], + "Jimmy Company": ["msp360"], + "Glaz-Tech Industries": ["msp360"], + "Grabb & Durando Law Office": ["msp360"], + "Bill Tedards": ["msp360"], + "Arizona Medical Transit": ["msp360"], + "Reliant Well Drilling": ["msp360"], + "The Prairie Schooner": ["msp360"], + "Janet Altschuler": ["msp360"], + "Design and Brand Envoys": ["msp360"], + "Mineralogical Record": ["owncloud"], + # Revenue leak (backing up but not billed) + "Cascades of Tucson": ["msp360"], + "Len's Auto Brokerage": ["msp360"], + "Russo Law Firm": ["msp360", "seafile"], + "Horseshoe Management": ["msp360"], + "Business Services of Tucson": ["msp360", "owncloud"], +} + +# MSP360 company names don't always match the canonical client names. +# This maps MSP360 CompanyName -> our CLIENT_BACKENDS key. +MSP360_ALIASES: dict[str, str] = { + "Valleywide Plastering": "Valley Wide Plastering", + "Glaztech Industries": "Glaz-Tech Industries", + "Grabb and Durando": "Grabb & Durando Law Office", + "Tedards": "Bill Tedards", + "Hartman": "Gary A Hartman LLC", + "Len's Auto": "Len's Auto Brokerage", +} + + +def log_error(msg: str) -> None: + try: + subprocess.run(["bash", LOGERR, "gps-audit", msg], + capture_output=True, timeout=10) + except Exception: + pass + + +def run_skill(cli: str, args: list[str], timeout: int = 120) -> Any: + """Run a skill CLI and parse its JSON output.""" + cmd = ["bash", PY_SH, cli] + args + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if r.returncode != 0: + return None + return json.loads(r.stdout) + except (json.JSONDecodeError, subprocess.TimeoutExpired) as e: + log_error(f"skill call failed: {cli} {args} -- {e}") + return None + + +def fetch_msp360() -> list[dict]: + """Fetch all MSP360 monitoring plans.""" + data = run_skill(MSP360_CLI, ["monitoring", "--json"]) + return data if isinstance(data, list) else [] + + +def fetch_seafile() -> list[dict]: + """Fetch Seafile user usage.""" + data = run_skill(SEAFILE_CLI, ["usage", "--json"]) + return data if isinstance(data, list) else [] + + +def fetch_owncloud() -> list[dict]: + """Fetch ownCloud user usage (slow -- SSH).""" + data = run_skill(OWNCLOUD_CLI, ["usage", "--json"], timeout=60) + return data if isinstance(data, list) else [] + + +def days_ago(ts_str: str | None) -> int | None: + """Parse an ISO timestamp and return days since now.""" + if not ts_str: + return None + try: + # Handle both aware and naive timestamps + ts_str = ts_str.rstrip("Z") + if "+" in ts_str or (ts_str.count("-") > 2): + # Has timezone offset + dt = datetime.fromisoformat(ts_str) + if dt.tzinfo: + return (datetime.now(timezone.utc) - dt).days + dt = datetime.fromisoformat(ts_str) + return (datetime.now(timezone.utc).replace(tzinfo=None) - dt).days + except (ValueError, TypeError): + return None + + +def grade_msp360_plan(plan: dict) -> str: + """Grade a single MSP360 plan: OK / STALE / FAILING.""" + status = plan.get("Status", -1) + last_start = plan.get("LastStart") + age = days_ago(last_start) + + if status != 0: + error = plan.get("ErrorMessage", "") + if error: + return f"FAILING ({error[:40]})" + return "FAILING" + + if age is not None and age > 7: + return f"STALE ({age}d)" + return "OK" + + +def normalize_client(name: str) -> str: + """Normalize client name for fuzzy matching.""" + return name.lower().strip().replace(" ", " ") + + +def resolve_msp360_company(company_name: str) -> str | None: + """Resolve an MSP360 company name to our canonical client name.""" + # Direct alias lookup first + if company_name in MSP360_ALIASES: + return MSP360_ALIASES[company_name] + # Exact match against CLIENT_BACKENDS keys + for client in CLIENT_BACKENDS: + if normalize_client(client) == normalize_client(company_name): + return client + # Substring match (either direction) + for client in CLIENT_BACKENDS: + cn = normalize_client(company_name) + ck = normalize_client(client) + if cn in ck or ck in cn: + return client + # First-word match (catches "Dataforth" matching "Dataforth Corp") + company_first = normalize_client(company_name).split()[0] if company_name else "" + for client in CLIENT_BACKENDS: + if normalize_client(client).split()[0] == company_first and len(company_first) > 3: + return client + return None + + +def build_report( + msp360_plans: list[dict], + seafile_users: list[dict], + owncloud_users: list[dict], + client_filter: str | None = None, +) -> list[dict]: + """Build unified per-client backup status rows.""" + rows = [] + + for client, backends in CLIENT_BACKENDS.items(): + if client_filter and normalize_client(client_filter) not in normalize_client(client): + continue + + row: dict[str, Any] = { + "client": client, + "backends": backends, + "msp360": [], + "seafile": [], + "owncloud": [], + "verdict": "UNKNOWN", + } + + # MSP360 + if "msp360" in backends: + for plan in msp360_plans: + co = plan.get("CompanyName", "") + resolved = resolve_msp360_company(co) + if resolved == client: + grade = grade_msp360_plan(plan) + row["msp360"].append({ + "computer": plan.get("ComputerName", "?"), + "plan": plan.get("PlanName", "?"), + "last_start": plan.get("LastStart"), + "age_days": days_ago(plan.get("LastStart")), + "status_code": plan.get("Status"), + "data_gb": round((plan.get("DataCopied") or 0) / 1e9, 1), + "grade": grade, + }) + + # Seafile + if "seafile" in backends: + for user in seafile_users: + email = user.get("email", "") + uname = user.get("name", "") + # Match by client name in email domain or display name + client_lower = normalize_client(client) + if any(client_lower.split()[0] in s.lower() for s in [email, uname]): + row["seafile"].append({ + "email": email, + "name": uname, + "used_gb": round(user.get("quota_used_gb", user.get("quota_used_bytes", 0) / 1e9), 1), + "last_access": user.get("last_access"), + "age_days": days_ago(user.get("last_access")), + }) + + # ownCloud + if "owncloud" in backends: + for user in owncloud_users: + uid = user.get("uid", "") + client_lower = normalize_client(client) + # Known mappings: minrec -> Mineralogical Record, bst -> Business Services + if (client_lower.startswith("mineral") and uid == "minrec") or \ + (client_lower.startswith("business") and uid == "bst") or \ + (normalize_client(uid) in client_lower): + row["owncloud"].append({ + "uid": uid, + "used_gb": user.get("used_gb", 0), + "enabled": user.get("enabled", False), + "last_login": user.get("last_login"), + }) + + # Compute verdict + all_grades = [p["grade"] for p in row["msp360"]] + ok_plans = sum(1 for g in all_grades if g == "OK") + failing_plans = sum(1 for g in all_grades if "FAILING" in g) + stale_plans = sum(1 for g in all_grades if "STALE" in g) + has_seafile = len(row["seafile"]) > 0 and any(s["used_gb"] > 0 for s in row["seafile"]) + has_owncloud = len(row["owncloud"]) > 0 and any(o["used_gb"] > 0 and o["enabled"] for o in row["owncloud"]) + + if not row["msp360"] and not has_seafile and not has_owncloud: + row["verdict"] = "FAIL (no backup found)" + elif ok_plans == 0 and not has_seafile and not has_owncloud: + if failing_plans > 0: + row["verdict"] = "FAIL (all plans failing)" + elif stale_plans > 0: + stale_detail = [g for g in all_grades if "STALE" in g][0] + row["verdict"] = f"REVIEW ({stale_detail})" + else: + row["verdict"] = "FAIL" + elif ok_plans > 0 or has_seafile or has_owncloud: + bad = failing_plans + stale_plans + if bad == 0: + row["verdict"] = "OK" + else: + row["verdict"] = f"OK ({ok_plans} plans OK, {bad} need attention)" + else: + row["verdict"] = "REVIEW" + + rows.append(row) + + # Sort: FAIL first, then REVIEW, then OK + order = {"FAIL": 0, "REVIEW": 1, "OK": 2, "UNKNOWN": 3} + rows.sort(key=lambda r: (order.get(r["verdict"].split()[0].rstrip("("), 3), r["client"])) + return rows + + +def print_table(rows: list[dict]) -> None: + """Print a human-readable summary table.""" + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + print(f"GPS Backup Audit Report -- {now_str}") + print(f"{'=' * 90}") + print(f"{'CLIENT':<35} {'BACKEND':<12} {'MACHINES':<10} {'LAST OK':<12} {'VERDICT'}") + print(f"{'-' * 90}") + + for row in rows: + backend_str = "+".join(row["backends"]) + + # Count machines + machine_count = len(row["msp360"]) + len(row["seafile"]) + len(row["owncloud"]) + + # Most recent OK backup + ages = [] + for p in row["msp360"]: + if p["grade"] == "OK" and p["age_days"] is not None: + ages.append(p["age_days"]) + for s in row["seafile"]: + if s["age_days"] is not None: + ages.append(s["age_days"]) + last_ok = f"{min(ages)}d ago" if ages else "-" + + print(f"{row['client'][:35]:<35} {backend_str:<12} {machine_count:<10} {last_ok:<12} {row['verdict']}") + + # Summary + total = len(rows) + ok = sum(1 for r in rows if r["verdict"].startswith("OK")) + review = sum(1 for r in rows if r["verdict"].startswith("REVIEW")) + fail = sum(1 for r in rows if r["verdict"].startswith("FAIL")) + unknown = sum(1 for r in rows if r["verdict"].startswith("UNKNOWN")) + + print(f"\n{'=' * 90}") + print(f"TOTAL: {total} clients | OK: {ok} | REVIEW: {review} | FAIL: {fail} | UNKNOWN: {unknown}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="GPS backup audit aggregator") + parser.add_argument("--json", action="store_true", help="JSON output") + parser.add_argument("--client", help="Filter to a single client (partial match)") + parser.add_argument("--failed", action="store_true", help="Show only FAIL/REVIEW clients") + parser.add_argument("--skip-owncloud", action="store_true", help="Skip ownCloud (slow SSH)") + parser.add_argument("--skip-datto", action="store_true", help="Skip Datto Workplace") + args = parser.parse_args() + + # Fetch from all backends + print("[INFO] Fetching MSP360 monitoring data...", file=sys.stderr) + msp360_plans = fetch_msp360() + print(f"[OK] MSP360: {len(msp360_plans)} plans", file=sys.stderr) + + print("[INFO] Fetching Seafile usage...", file=sys.stderr) + seafile_users = fetch_seafile() + print(f"[OK] Seafile: {len(seafile_users)} users", file=sys.stderr) + + owncloud_users: list[dict] = [] + if not args.skip_owncloud: + print("[INFO] Fetching ownCloud usage (SSH, may take a moment)...", file=sys.stderr) + owncloud_users = fetch_owncloud() + print(f"[OK] ownCloud: {len(owncloud_users)} users", file=sys.stderr) + else: + print("[SKIP] ownCloud", file=sys.stderr) + + # Build the report + rows = build_report(msp360_plans, seafile_users, owncloud_users, args.client) + + if args.failed: + rows = [r for r in rows if not r["verdict"].startswith("OK")] + + if args.json: + print(json.dumps(rows, indent=2, default=str)) + else: + print_table(rows) + + # Return non-zero if any FAIL + if any(r["verdict"].startswith("FAIL") for r in rows): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())