Session log: /save + /sync multi-user change summaries

Enhance /save and /sync slash commands to attribute commits by author
so Mike and Howard can see at a glance what the other person did.

- sync.sh: loads identity.json, shows incoming/outgoing commits with
  author + age before pull/push, groups by author in final summary
- sync.md: describes the new output format + conflict attribution
- save.md: pre-commit Change Summary block + post-commit Summary

Motivation: repo is now shared across team, `git log` alone made it
hard to see "when did Howard change that?" without hunting.
This commit is contained in:
2026-04-16 19:08:25 -07:00
parent 100a491ac6
commit 6f6a77f8e4
4 changed files with 430 additions and 76 deletions

View File

@@ -72,9 +72,32 @@ Format credentials as:
## After Saving
Before committing, emit a **Change Summary** block for the user to review:
```
## Change Summary (this session)
User: <full_name> (from .claude/identity.json)
Machine: <HOSTNAME>
Files changed:
<output of: git status --short>
Stats:
<output of: git diff --stat HEAD>
```
Then:
1. Commit with message: "Session log: [brief description of work done]"
2. Push to gitea remote (if configured)
3. Confirm push was successful
3. After push, emit a **Post-commit Summary**:
- New commit SHA + message
- Author (from `git log -1 --format='%an <%ae>'`)
- Files in the commit (from `git show --stat HEAD`)
4. Confirm push was successful
### Why the summary
In the multi-user setup, commits can land in `main` from either team member. Always attributing author + files makes it obvious who made what change when someone else pulls the repo. Saves re-reading diffs to figure out "wait, when did that happen?"
## Purpose

View File

@@ -7,23 +7,33 @@ bash .claude/scripts/sync.sh
```
The script automatically:
1. Stages and commits local changes (if any)
2. Fetches and pulls remote changes
3. Pushes local changes
4. Reports sync status
1. Stages and commits local changes (attributed to the current user from `.claude/identity.json`)
2. Fetches remote and shows **incoming commits with authors** before pulling
3. Shows **outgoing commits with authors** before pushing
4. Pulls (rebase), then pushes
5. Prints a final change summary (who committed what, on which side)
After the script completes, report the 3 most recent session logs:
## After the script completes
The script emits a "Sync Summary" block. Relay the key bits to the user:
- **Incoming from remote:** N commits. If N > 0, list commits as `<short-sha> <author> — <message>` so the user immediately sees what Howard / Mike / other teammates pushed since their last sync.
- **Outgoing to remote:** M commits by the current user (this is what they're publishing).
- **Net file changes in this sync:** output of `git diff --stat <prev-HEAD>..HEAD -- . ':(exclude)session-logs'` (or similar scoping) so the user sees the meaningful edits, not noise.
Then report the 3 most recent session logs:
```bash
ls -t session-logs/*.md projects/*/session-logs/*.md clients/*/session-logs/*.md 2>/dev/null | head -3
```
## Conflict Resolution
## Conflict resolution
- **Session logs:** Keep both, rename with machine suffix
- **credentials.md:** Do NOT auto-merge, report to user
- **Other files:** Standard git conflict resolution
- **Session logs:** Keep both, rename with machine suffix. Note which user authored each conflicting side.
- **credentials.md:** Do NOT auto-merge, report to user.
- **Other files:** Standard git conflict resolution. When presenting a conflict, include the author of the conflicting commits on each side so the user can coordinate (e.g., "Howard changed this in commit abc123 on 2026-04-15").
## Error Handling
## Error handling
If push fails with auth error, retry once (transient Gitea auth issue).
If pull fails with conflicts, report affected files and ask for guidance.
- **Auth failure on push:** retry once (transient Gitea auth issue).
- **Pull conflicts:** report affected files + author of each conflicting side, then ask for guidance.
- **No identity.json yet:** follow the onboarding flow in CLAUDE.md before syncing.

View File

@@ -1,118 +1,158 @@
#!/bin/bash
# ClaudeTools Bidirectional Sync Script
# Ensures proper pull BEFORE push on all machines
# Prints incoming/outgoing change summary with author attribution
set -e # Exit on error
set -e
# Colors for output
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
CYAN='\033[0;36m'
NC='\033[0m'
# Detect machine name
# Machine + timestamp
if [ -n "$COMPUTERNAME" ]; then
MACHINE="$COMPUTERNAME"
else
MACHINE=$(hostname)
fi
# Timestamp
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
echo -e "${GREEN}[OK]${NC} Starting ClaudeTools sync from $MACHINE at $TIMESTAMP"
# Navigate to ClaudeTools directory
if [ -d "$HOME/ClaudeTools" ]; then
cd "$HOME/ClaudeTools"
elif [ -d "/d/ClaudeTools" ]; then
cd "/d/ClaudeTools"
elif [ -d "D:/ClaudeTools" ]; then
cd "D:/ClaudeTools"
else
echo -e "${RED}[ERROR]${NC} ClaudeTools directory not found"
# Navigate to ClaudeTools directory (check common locations)
for candidate in "$HOME/ClaudeTools" "/d/ClaudeTools" "D:/ClaudeTools" "/d/claudetools" "D:/claudetools"; do
if [ -d "$candidate" ]; then
cd "$candidate"
break
fi
done
if [ ! -d ".git" ]; then
echo -e "${RED}[ERROR]${NC} Not in a git working tree"
exit 1
fi
echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)"
# Phase 1: Check and commit local changes
# Load user identity
USER_DISPLAY="unknown"
USER_GITEA=""
if [ -f ".claude/identity.json" ]; then
USER_DISPLAY=$(python -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('full_name', d.get('user','unknown')))" 2>/dev/null || echo "unknown")
USER_GITEA=$(python -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
fi
echo -e "${GREEN}[OK]${NC} Syncing as: $USER_DISPLAY (machine: $MACHINE)"
# Phase 1: Local changes
echo ""
echo "=== Phase 1: Local Changes ==="
echo "=== Phase 1: Local changes ==="
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
echo -e "${YELLOW}[INFO]${NC} Local changes detected"
# Show status
echo -e "${YELLOW}[INFO]${NC} Local changes detected:"
git status --short
echo ""
# Stage all changes
echo -e "${GREEN}[OK]${NC} Staging all changes..."
git add -A
# Commit with timestamp
COMMIT_MSG="sync: Auto-sync from $MACHINE at $TIMESTAMP
Synced files:
- Session logs updated
- Latest context and credentials
- Command/directive updates
# Commit message (Co-Authored-By uses local git user if configured)
COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP
Author: $USER_DISPLAY
Machine: $MACHINE
Timestamp: $TIMESTAMP
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
Timestamp: $TIMESTAMP"
git commit -m "$COMMIT_MSG"
echo -e "${GREEN}[OK]${NC} Changes committed"
echo -e "${GREEN}[OK]${NC} Committed."
else
echo -e "${GREEN}[OK]${NC} No local changes to commit"
echo -e "${GREEN}[OK]${NC} No local changes to commit."
fi
# Phase 2: Sync with remote (CRITICAL: Pull BEFORE Push)
# Phase 2: Remote sync
echo ""
echo "=== Phase 2: Remote Sync (Pull + Push) ==="
echo "=== Phase 2: Fetch + inspect ==="
# Fetch to see what's available
echo -e "${GREEN}[OK]${NC} Fetching from remote..."
git fetch origin
LOCAL_BEFORE=$(git rev-parse HEAD)
# Check if remote has updates
LOCAL=$(git rev-parse main)
REMOTE=$(git rev-parse origin/main)
echo -e "${GREEN}[OK]${NC} Fetching from origin..."
git fetch origin --quiet
if [ "$LOCAL" != "$REMOTE" ]; then
echo -e "${YELLOW}[INFO]${NC} Remote has updates, pulling..."
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null || echo "$LOCAL")
REMOTE_BRANCH="origin/main"
if ! git rev-parse origin/main >/dev/null 2>&1; then
REMOTE_BRANCH="origin/master"
fi
# Pull with rebase
# Count and show incoming
INCOMING_COUNT=$(git rev-list --count HEAD..$REMOTE_BRANCH 2>/dev/null || echo 0)
OUTGOING_COUNT=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
if [ "$INCOMING_COUNT" -gt 0 ]; then
echo ""
echo -e "${CYAN}--- Incoming: $INCOMING_COUNT commits from remote ---${NC}"
git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' HEAD..$REMOTE_BRANCH | head -30
echo ""
echo -e "${CYAN}--- Files touched by incoming commits ---${NC}"
git diff --stat HEAD..$REMOTE_BRANCH | tail -20
else
echo -e "${GREEN}[OK]${NC} No incoming changes."
fi
if [ "$OUTGOING_COUNT" -gt 0 ]; then
echo ""
echo -e "${CYAN}--- Outgoing: $OUTGOING_COUNT commits to remote ---${NC}"
git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' $REMOTE_BRANCH..HEAD | head -30
fi
# Phase 3: Pull (if needed)
if [ "$INCOMING_COUNT" -gt 0 ]; then
echo ""
echo "=== Phase 3: Pull (rebase) ==="
if git pull origin main --rebase; then
echo -e "${GREEN}[OK]${NC} Successfully pulled remote changes"
git log --oneline "$LOCAL..origin/main"
echo -e "${GREEN}[OK]${NC} Pulled successfully."
else
echo -e "${RED}[ERROR]${NC} Pull failed - may have conflicts"
echo -e "${YELLOW}[INFO]${NC} Resolve conflicts and run sync again"
echo -e "${RED}[ERROR]${NC} Pull failed (likely conflicts). Resolve and re-run sync."
exit 1
fi
fi
# Phase 4: Push (if needed)
OUTGOING_AFTER_PULL=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
echo ""
echo "=== Phase 4: Push ==="
if git push origin main; then
echo -e "${GREEN}[OK]${NC} Pushed successfully."
else
echo -e "${RED}[ERROR]${NC} Push failed. Check auth / network."
exit 1
fi
else
echo -e "${GREEN}[OK]${NC} Already up to date with remote"
echo -e "${GREEN}[OK]${NC} Nothing to push."
fi
# Push local changes
# Phase 5: Summary
echo ""
echo -e "${GREEN}[OK]${NC} Pushing local changes to remote..."
if git push origin main; then
echo -e "${GREEN}[OK]${NC} Successfully pushed to remote"
else
echo -e "${RED}[ERROR]${NC} Push failed"
exit 1
echo "=== Sync Summary ==="
if [ "$INCOMING_COUNT" -gt 0 ]; then
# Count commits by author
INCOMING_AUTHORS=$(git log --format='%an' $LOCAL_BEFORE..HEAD 2>/dev/null | sort | uniq -c | sort -rn | awk '{printf "%s (%s), ", substr($0, index($0,$2)), $1}' | sed 's/, $//')
echo -e "${CYAN}Pulled in:${NC} $INCOMING_COUNT commit(s) — authors: ${INCOMING_AUTHORS:-unknown}"
fi
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
echo -e "${CYAN}Pushed out:${NC} $OUTGOING_AFTER_PULL commit(s) by $USER_DISPLAY"
fi
if [ "$INCOMING_COUNT" -eq 0 ] && [ "$OUTGOING_AFTER_PULL" -eq 0 ]; then
echo -e "${GREEN}Already in sync — no commits moved in either direction.${NC}"
fi
# Phase 3: Report final status
echo ""
echo "=== Sync Complete ==="
echo -e "${GREEN}[OK]${NC} Local branch: $(git rev-parse --abbrev-ref HEAD)"
echo -e "${GREEN}[OK]${NC} Current commit: $(git log -1 --oneline)"
echo -e "${GREEN}[OK]${NC} Remote status: $(git status -sb | head -1)"
echo -e "${GREEN}[OK]${NC} HEAD: $(git log -1 --oneline)"
echo -e "${GREEN}[OK]${NC} Status: $(git status -sb | head -1)"
echo ""
echo -e "${GREEN}[SUCCESS]${NC} All machines in sync. Ready to continue work."
echo -e "${GREEN}[SUCCESS]${NC} Sync complete."

View File

@@ -690,3 +690,284 @@ Claude: [Already has context, proceeds immediately with work]
**Status:** Automatic context loading system complete and deployed ✅
**Impact:** Eliminates recurring problem of Claude not knowing previous work
**Validation:** To be tested in next session with fresh Claude instance
---
## Update: 17:30 UTC — MSP tooling + incident response + remediation skill
### User
- **User:** Mike Swanson (mike)
- **Machine:** DESKTOP-0O8A1RL
- **Role:** admin
### Session summary
Separate session later the same day (different machine/Claude instance from the context-loading work above). Five interleaved threads:
1. **Cascades Tucson breach investigation** — John Trozzi reported as possible credential-stuffing victim. Check found John clean; tenant-wide sweep discovered **Megan Hiatt under active credential-stuffing attack** RIGHT NOW (bursts from Belfast GB, Hamburg DE).
2. **Built `/remediation-tool` skill + slash command** codifying the M365 investigation workflow.
3. **Fixed SOPS `vault.sh` on Windows** — Device Guard (WDAC) blocks unsigned `yq.exe`; added Python + PyYAML fallback.
4. **Valleywide RemoteApp-over-VPN troubleshooting** — walked through `0x3000008` -> NXDOMAIN -> RDS licensing in sequence.
5. **Howard Enos breach check** — clean, but actively targeted on cloud-admin paths (Azure CLI/LU, AAD PowerShell/DE+JP).
### Thread 1: Cascades Tucson breach investigation
**John Trozzi (`john.trozzi@cascadestucson.com`, `a638f4b9-6936-4401-a9b7-015b9900e49e`)** — tenant `207fa277-e9d8-4eb7-ada1-1064d2221498`.
Verdict: **NO BREACH.** All 10 breach checks clean.
- No Graph inbox rules; one Exchange hidden rule (`Junk E-mail Rule` — default)
- No forwarding, no delegates, no non-SELF SendAs
- 2 OAuth grants (both BlueMail, consented 2022)
- 5 auth methods all pre-dating attack window (MS Authenticator on Samsung SM-F731U + FIDO2 passkey, both 2026-02-12)
- 30d sign-ins: 11, 100% from `184.191.143.62` Phoenix AZ (Cox)
- Directory audits show the legit IR sequence by sysadmin (disable -> password reset -> enable), then John self-changed at 16:04:46 UTC
**Tenant-wide sweep flagged PRIORITY 1: Megan Hiatt (`megan.hiatt@cascadestucson.com`) under active credential-stuffing:**
- **126 failed sign-ins in 30 days** across 8 IPs / 6 countries (CH, DE, GB, LT, NL, US)
- **Today (2026-04-16 15:5816:01 UTC):** 23 failures from `80.94.92.102` (Belfast, GB) via Authenticated SMTP
- Earlier: 2026-04-15 Hamburg DE (`158.94.211.16`), 2026-04-13 Belfast GB (`80.94.92.123`)
- Password last changed 2026-02-18 (~2 months stale)
- Only 1 MFA method (MS Authenticator iPhone 13)
- Mailbox clean. NOT breached — MFA + IP reputation + account lockout holding.
- **Action items:** reset Megan's password, disable SMTP AUTH on her mailbox, keep monitoring.
Other notable: external guest `dunedolly21@gmail.com` invited 2026-04-14 by `lauren.hasselman@cascadestucson.com` from her mobile. Lauren's activity is clean. Mike to confirm with Lauren what the invite is for. No meaningful access granted yet.
Gaps encountered and addressed during investigation:
- Exchange Admin role was not assigned to `ComputerGuru - AI Remediation` SP in Cascades. Mike assigned it via Entra UI. ~15 min to propagate. Unlocked hidden-rule / delegate / SendAs checks.
- IdentityRiskyUser scope still NOT consented in Cascades. Consent URL opened multiple times but `/servicePrincipals/{id}/appRoleAssignments` shows no new grants today — permission may not be in the app manifest. Mike to verify home-tenant app registration.
**Report:** `clients/cascades-tucson/reports/2026-04-16-john-breach-check.md`
### Thread 2: Built `/remediation-tool` skill
Codified the Cascades workflow into a reusable skill. Files:
```
.claude/commands/remediation-tool.md
.claude/skills/remediation-tool/
├── SKILL.md # auto-invocation triggers
├── scripts/
│ ├── resolve-tenant.sh # domain -> tenant GUID via OpenID discovery
│ ├── get-token.sh # Graph + Exchange tokens, 55-min cache
│ ├── user-breach-check.sh # 10-point user check
│ └── tenant-sweep.sh # tenant-wide signals
├── references/
│ ├── gotchas.md # role prereqs, consent URLs, display name quirk
│ ├── graph-endpoints.md # Graph + Exchange REST cheatsheet
│ └── checklist.md # breach-check rubric
└── templates/breach-report.md # report skeleton
```
Subcommands:
```
/remediation-tool check <upn>
/remediation-tool sweep <domain>
/remediation-tool signins <domain> [--user upn] [--failed-only] [--days N]
/remediation-tool consent-url <domain>
/remediation-tool remediate <upn> <action> # gated — requires YES in chat
```
Auth flow: resolve tenant ID from domain via OpenID discovery -> pull secret from SOPS vault -> acquire client-credentials tokens -> run checks -> dump raw JSON to `/tmp/remediation-tool/{tenant}/{check}/` -> write report to `clients/{slug}/reports/YYYY-MM-DD-{action}.md`.
Updated:
- `.claude/CLAUDE.md` — added `/remediation-tool` row to Commands & Skills table
- `.claude/memory/feedback_365_remediation_tool.md` — cross-reference to the skill
Smoke-tested end-to-end against Cascades (token acquired, Graph /organization call returned correct tenant) and Howard (full 10-point check in ~5 seconds).
**App identity gotchas captured in references/gotchas.md:**
- App ID: `fabb3421-8b34-484b-bc17-e46de9703418`
- Home-tenant name: Claude-MSP-Access
- **Customer-tenant display name: ComputerGuru - AI Remediation** (important when searching role assignment dialogs)
- Client secret: vault `msp-tools/claude-msp-access-graph-api.sops.yaml`, field `credentials.credential`
### Thread 3: Vault.sh Device Guard fix
**Root cause:** `yq.exe` on this Windows box is blocked by corporate Device Guard / WDAC policy (unsigned binary). Both the WinGet `Links` shim and the real binary at `C:/Users/guru/AppData/Local/Microsoft/WinGet/Packages/MikeFarah.yq_.../yq.exe` return "Permission denied" / "blocked by your organization's Device Guard policy".
**Fix:** Replaced yq dependency with Python + PyYAML fallback.
Files:
- **New:** `D:/vault/scripts/yaml-query.py` — minimal yq replacement, two commands (`path <dot.path>`, `flatten-env <key>`)
- **Modified:** `D:/vault/scripts/vault.sh` — added `_detect_yq_mode`, `_yaml_field`, `_yaml_flatten_env` helpers; replaced two `yq eval` call sites. Prefers yq if it works, falls back to Python.
Verified:
- `vault.sh get-field msp-tools/claude-msp-access-graph-api.sops.yaml credentials.credential` -> returns `~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO`
- `vault.sh export-env ...` -> `CREDENTIAL=~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO`
- `vault.sh search`, `vault.sh list` unchanged
PyYAML 6.0.3 already installed at `/c/Program Files/Python314/python`.
**Defender alerts fired** during the fix (rapid SOPS decryption + JWT base64-decoding + client-credentials OAuth looked like credential-dumping heuristics). All false positives. Mike left exclusions unchanged; future runs will hit the 55-min token cache and quiet down.
### Thread 4: Valleywide RemoteApp over VPN (three sequential problems)
**Problem 1 — `0x3000008` (RD Gateway unreachable):** Public WAN :443 port-forward to VWP-QBS was removed during 2026-04-13 brute-force IR. RDP manifest still routed through external FQDN `remote.valleywideplastering.com` -> WAN IP `4.18.160.106` (firewalled).
Fix: Mike removed RD Gateway from RDS deployment on VWP-QBS (Server Manager -> RDS -> Edit Deployment Properties -> RD Gateway -> "Do not use"). New RDP files have `gatewayusagemethod:i:0` and `full address:s:VWP-QBS.VWP.US`.
**Problem 2 — NXDOMAIN for `VWP-QBS.VWP.US`:** After gateway removed, client tried to resolve the session host hostname directly. UDM's static DNS had a typo: `qwp-qbs.vwp.us` (Q not V). `vwp.us` is a real registered domain (website lives publicly), so external DNS doesn't help; internal override needed.
Fix: Mike edited UniFi UI (Settings -> Routing -> DNS -> Static DNS Records), changed `qwp-qbs.vwp.us` -> `vwp-qbs.vwp.us`, still pointing at `172.16.9.169`.
**Problem 3 — "No Remote Desktop License Servers available" (0x3, 0x101):** Once DNS resolved and client reached session host, RDS-Licensing role was installed + activated locally on VWP-QBS but the RDSH was never configured to use it.
Fix (applied remotely via WinRM over VPN from Mike's box):
```powershell
$ts = Get-CimInstance -Namespace root\cimv2\TerminalServices -ClassName Win32_TerminalServiceSetting
Invoke-CimMethod -InputObject $ts -MethodName ChangeMode -Arguments @{LicensingType = 4} # Per User
Invoke-CimMethod -InputObject $ts -MethodName SetSpecifiedLicenseServerList -Arguments @{SpecifiedLSList = @('vwp-qbs.vwp.us')}
```
Both returned `ReturnValue=0`. Mike confirmed "it works".
**Outstanding VWP issue:** License server has only the Windows 2000-era `Built-in TS Per Device CAL` placeholder — **no real CALs**. Grace period is consumed. Purchase needed: **Windows Server 2022 RDS Per User CAL pack** sized to active user count; install via `licmgr.msc` on VWP-QBS.
### VWP UDM access
- Host: `172.16.9.1` — UniFi Dream Machine Pro, firmware 5.0.16
- Access: SSH as `root` via ed25519 key (added during this session via PuTTY after UI-based add didn't land)
- Public key added: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINXR2BOcFAlOPuB7OYOKfOZDNd3u1tCt/IINRH9beFyB guru@DESKTOP-0O8A1RL`
- Fingerprint: `SHA256:ZVbowRHhxPX47eKy9FyMwjvIKPzTf3Dwx3BCsBrP4ds`
- Vault entry password `Gptf*77ttb123!@#-vwp` does NOT work — needs rotation + vault update
### VWP network topology (discovered)
- LAN: `172.16.9.0/24` (br0 — VWP-QBS at `.169`), `192.168.0.0/24` (br2 — legacy), `192.168.3.0/24` (br99 — iDRAC)
- WAN: `eth8` = `4.18.160.106/30`
- OpenVPN server on `tun1` — clients land on `192.168.4.0/24`, DNS pushed = `192.168.4.1` (UDM), routes pushed for all three LAN subnets
- WireGuard site-to-site peers: `wgsts1001` (192.168.5.2), `wgsts1003` (192.168.5.6), `wgsts1005` (192.168.5.11) — learn OSPF routes for 192.168.1.0/24 and 192.168.10.0/24
- VPN -> LAN firewall: `UBIOS_VPN_LAN_USER` = ACCEPT all
- Active port forwards: NONE (DNAT hook empty after 2026-04-13 removal)
`clients/valleywide/README.md` appended with `## 2026-04-16` section documenting all three fixes, topology, and CAL-purchase action item.
### Thread 5: Howard Enos breach check (own tenant)
Invoked `/remediation-tool check howard@azcomputerguru.com`.
- **Tenant:** azcomputerguru.com, `ce61461e-81a0-4c84-bb4a-7b354a9a356d`
- **UPN:** howard@azcomputerguru.com, object id `c99de3bd-ddc1-43f1-907f-e84b91273660`
- **Password last changed:** 2024-09-24 (18 months ago)
Verdict: **CLEAN, but actively targeted on cloud-admin paths.**
- **174 of 200 sign-ins non-US in 30d — 100% FAILED, zero successful foreign sign-ins**
- Top attackers: CN(32), IN(32), KR(28), LU(15 via **Microsoft Azure CLI**), BR(14), DE(8 via **Azure AD PowerShell**), JP(8 via **AAD PowerShell**), plus 19 other countries.
- Attacker is specifically probing admin-grade endpoints, not just random Exchange.
- 3 inbox rules — all legit user filters (Telnyx, Atlas_LNP whitelabel, Facebook)
- 4 OAuth grants — standard Microsoft Graph + Teams
- 8 app role assignments — all MSP-relevant (Syncro v1+v2, ASUS, Tailscale, Perfect Wiki, KaseyaSSO, Graph Explorer, Uizard)
- 6 auth methods — password + SMS + OATH + 3x MS Authenticator (phone upgrades)
**Gaps on our own tenant:**
- Exchange Admin role NOT assigned to ComputerGuru-AI-Remediation SP in azcomputerguru -> blocks hidden-rule / delegate / SendAs checks
- IdentityRiskyUser NOT consented in azcomputerguru
**Report:** `clients/internal-infrastructure/reports/2026-04-16-howard-breach-check.md`
### Credentials & secrets
**Claude-MSP-Access Graph API app ("ComputerGuru - AI Remediation"):**
- App ID: `fabb3421-8b34-484b-bc17-e46de9703418`
- Client Secret: `~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO`
- Vault: `msp-tools/claude-msp-access-graph-api.sops.yaml`, field `credentials.credential`
- Admin consent URL: `https://login.microsoftonline.com/{tenant-id}/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient`
**VWP (Valleywide):**
- Domain admin: `vwp\sysadmin` / `r3tr0gradE99#`
- Vault: `clients/vwp/{adsrvr,dc1,udm,xenserver,quickbooks-server-idrac}.sops.yaml`
- UDM root (vault says, but broken): `Gptf*77ttb123!@#-vwp` -> ROTATE + update vault
- UDM SSH: `ssh root@172.16.9.1` via ed25519 key
- VWP_ADSRVR SSH: `ssh vwp\guru@192.168.0.25` (key from 2026-04-13)
**Cascades Tucson:** Tenant `207fa277-e9d8-4eb7-ada1-1064d2221498`, admin `sysadmin@cascadestucson.com`
**AZ Computer Guru:** Tenant `ce61461e-81a0-4c84-bb4a-7b354a9a356d`
### Files created / modified in this update block
**New:**
- `.claude/commands/remediation-tool.md`
- `.claude/skills/remediation-tool/SKILL.md`
- `.claude/skills/remediation-tool/scripts/{resolve-tenant.sh,get-token.sh,user-breach-check.sh,tenant-sweep.sh}`
- `.claude/skills/remediation-tool/references/{gotchas.md,graph-endpoints.md,checklist.md}`
- `.claude/skills/remediation-tool/templates/breach-report.md`
- `D:/vault/scripts/yaml-query.py`
- `clients/cascades-tucson/reports/2026-04-16-john-breach-check.md`
- `clients/internal-infrastructure/reports/2026-04-16-howard-breach-check.md`
**Modified:**
- `.claude/CLAUDE.md` — added `/remediation-tool` row
- `.claude/memory/feedback_365_remediation_tool.md` — cross-reference
- `clients/valleywide/README.md` — 2026-04-16 section (RemoteApp + RDS licensing + CAL TODO)
- `D:/vault/scripts/vault.sh` — Python fallback for yq
### Pending / incomplete
1. Cascades — **reset Megan's password + disable SMTP AUTH** on her mailbox
2. Cascades — confirm `dunedolly21@gmail.com` invite with Lauren Hasselman
3. Cascades — verify IdentityRiskyUser.ReadWrite.All actually in the app manifest; re-run consent URL
4. Howard — password rotation (18 months old); consider passwordless/FIDO2 primary
5. Own tenant (azcomputerguru) — assign Exchange Admin role + consent IdentityRiskyUser on ComputerGuru-AI-Remediation SP (oversight)
6. Own tenant — verify CA policies block legacy auth (attacker hitting basic auth + AAD PowerShell paths)
7. VWP — purchase Server 2022 RDS Per User CAL pack, install via licmgr.msc
8. VWP — rotate UDM root password, update vault
9. VWP — UPnP audit on UDM (carried from 2026-04-13)
10. VWP — rotate `scanner` AD account password (carried from 2026-04-13)
### Key references
- Skill invocation: `/remediation-tool {check|sweep|signins|consent-url|remediate} <target> [flags]`
- Raw JSON artifacts: `/tmp/remediation-tool/{tenant-id}/{check}/`
- Report directory pattern: `clients/{slug}/reports/YYYY-MM-DD-{action}.md`
- Gotchas: `.claude/skills/remediation-tool/references/gotchas.md`
- Graph endpoints: `.claude/skills/remediation-tool/references/graph-endpoints.md`
- Memory: `.claude/memory/feedback_365_remediation_tool.md`
**Update end:** 2026-04-16 ~17:45 UTC
**Outcome:** Cascades incident triaged (John clean, Megan actively attacked but holding); `/remediation-tool` skill live and tested; vault working on Windows; Valleywide RemoteApp restored; Howard clean but targeted.
---
## Update: 19:00 UTC — /save + /sync multi-user change summaries
### Motivation
The repo is now shared between Mike and Howard (per CLAUDE.md's new multi-user section). When either person pulls `main`, they want to know **what changed and who did it** without re-reading diffs. Mike asked `/save` and `/sync` to surface that automatically.
### Changes
**`.claude/commands/sync.md`** — rewrote to describe the new behavior: pre-pull incoming summary (sha / author / subject / age + `git diff --stat`), pre-push outgoing summary, post-sync totals by author. Conflict-resolution guidance now includes author attribution of each conflicting side.
**`.claude/scripts/sync.sh`** — rewrote. Now:
- Loads `.claude/identity.json` to pick up current user's full name
- Commit message replaces the old "Claude Sonnet 4.5 co-author" boilerplate with user + machine attribution
- Before pulling: prints incoming commits as `sha author subject (ago)` plus a `git diff --stat`
- Before pushing: prints outgoing commits the same way
- End-of-run "Sync Summary" counts commits by author on each side
- Also added the `D:/claudetools` / `/d/claudetools` lowercase variants to the directory-search list (was hitting only TitleCase)
**`.claude/commands/save.md`** — added a pre-commit **Change Summary** block (user + machine + `git status --short` + diff stats) and a post-commit summary (SHA + author + files in commit), with a "why" paragraph about multi-user attribution.
### Design notes
- Author attribution is `%an` from git (the person who made the commit), not the shared push account. Since each user has their own `user.email` + `user.name` set from identity.json during onboarding, `%an` carries the real person.
- For incoming commits viewed before pull, `%an` works because fetch pulls the commit objects with their original author metadata.
- Summaries are emitted by the bash script (sync) or by Claude following the command spec (save), not by a git hook. Keeps the behavior visible in normal terminal output when a user runs sync by hand.
### Syntax-checked
`bash -n .claude/scripts/sync.sh` — OK.
### Files touched in this micro-update
- `.claude/commands/sync.md` — rewritten
- `.claude/scripts/sync.sh` — rewritten
- `.claude/commands/save.md` — edited (added "After Saving" section)
### Pending from this block
- Actually commit + push everything accumulated in today's session (skill directory, reports, README updates, command updates, this log). Delegated to Gitea agent next.
**Update end:** 2026-04-16 ~19:00 UTC