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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:04:09 -07:00
parent a79a107142
commit d1e41f0bb8
5 changed files with 404 additions and 2 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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 <url>`, 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 "<skill>" "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]].

View File

@@ -19,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line -->
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]

View File

@@ -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())