Compare commits
346 Commits
9a9b49c808
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d5a535a4 | |||
| 37a6598ef7 | |||
| a2a9356a02 | |||
| c3ad79d9fe | |||
| dfc237e6d4 | |||
| 14ac542a00 | |||
| c1706393b9 | |||
| e08f6c534e | |||
| f88c7d1739 | |||
| d5f7eadf82 | |||
| c289ba3370 | |||
| 60b307316c | |||
| 355c5f1e3f | |||
| d70cdd88e4 | |||
| faf796386a | |||
| 8b08ec64c2 | |||
| da6567ffe0 | |||
| 3d8aa2be59 | |||
| a8e76ba215 | |||
| 976bfe7957 | |||
| b0ebd44c9d | |||
| f274eed8fa | |||
| 5e682c9293 | |||
| bee140c020 | |||
| a737c4ab24 | |||
| fa4867580f | |||
| a5b8f6e7b1 | |||
| 6ad608c3bf | |||
| ea84b7bcd2 | |||
| b835f350d6 | |||
| 5e1ebf4789 | |||
| 656101a7b4 | |||
| 7c0f467cdf | |||
| 26b1cf400f | |||
| e359702837 | |||
| 67822d5ca8 | |||
| 346832b469 | |||
| a7174a894e | |||
| 3600df76cf | |||
| 9efa6eaf4f | |||
| 9542de2b0a | |||
| a0998fa255 | |||
| c8036d7e67 | |||
| 567fd8dd8c | |||
| 66211a5652 | |||
| 46705d48ff | |||
| e6dae3dba2 | |||
| 46d4900229 | |||
| 65b6043cde | |||
| 7a6fbcfc29 | |||
| 371fce1104 | |||
| 42d43a5bcc | |||
| a270f405a6 | |||
| a6a6a477d5 | |||
| b8bba3cd8f | |||
| 011ee7350d | |||
| 21b198f1ee | |||
| 6d36f7151c | |||
| edd4bd39e7 | |||
| ddd6bb06c2 | |||
| b97223f69e | |||
| f64418d5e1 | |||
| 2aed9d93c9 | |||
| cb99daa6cb | |||
| d7bc2b34ac | |||
| 734a7b201d | |||
| d4a1bbbc5b | |||
| a733db1029 | |||
| d5020a6415 | |||
| 0da31cbb65 | |||
| ff7025d912 | |||
| ad6a529ba4 | |||
| 60a0a5728f | |||
| 10b1ea7528 | |||
| 85c8149495 | |||
| a8ed995979 | |||
| ac05230cd2 | |||
| a1f0a3e5e8 | |||
| 4e739abe5f | |||
| 1ab0e88e67 | |||
| 0ca8e384d8 | |||
| fad05480a5 | |||
| e71a486db2 | |||
| 2e167c97fa | |||
| e2d902a337 | |||
| c18e74ed1c | |||
| fceb7c6075 | |||
| 442f3cb1c7 | |||
| f61088dac4 | |||
| 63e1eb743b | |||
| c82c1c76bb | |||
| e0e3dd0d82 | |||
| 78f794a924 | |||
| 41c12a934f | |||
| 727a0757f6 | |||
| 6cb90c5b09 | |||
| 8ecb406571 | |||
| 5e4b2cf385 | |||
| d745c7d40f | |||
| 7300f57a47 | |||
| 101b95e610 | |||
| 27b9966dfa | |||
| c54ca5ec22 | |||
| 75f60df6a6 | |||
| 59b5f1f5f2 | |||
| 7ff092f7bb | |||
| b01105d1f9 | |||
| 8152476ee4 | |||
| 42da3cfcca | |||
| 5008ad79dc | |||
| b0cf2a31ba | |||
| 552760e7cd | |||
| ccfa4f7b21 | |||
|
|
ac23f17e23 | ||
| 26f47fdd10 | |||
| 3e6f946377 | |||
| 30c7b26af2 | |||
| 1264cbda7b | |||
| b07613c127 | |||
| 2937b00ebf | |||
| 1775571abb | |||
| 282c4af8cc | |||
| c3aeef60fb | |||
| 875048f9ad | |||
| 142afd7e98 | |||
| 486b72ec71 | |||
| 9e78a153f3 | |||
| 7f897ce93f | |||
| af8a3de00e | |||
| 29355584bf | |||
| e527b89999 | |||
| 6f7f939a62 | |||
| e583bf43a5 | |||
| 02845878d6 | |||
| 6f676672a8 | |||
| 6251044baf | |||
| 9951e88f69 | |||
| be9574a480 | |||
| ab640dfe77 | |||
| 01613697c6 | |||
| 1b0b313896 | |||
| 152513b15d | |||
| a808200dc4 | |||
| f6f6aae618 | |||
| 2fc6afb121 | |||
| f17d56d717 | |||
| 5db3a27685 | |||
| bc6dde5b89 | |||
| 51335db124 | |||
| 5e92c33b73 | |||
| 6cc0c08ac4 | |||
| 769f099f70 | |||
| 5c87b83556 | |||
| 801ff788a6 | |||
| d2da2bb334 | |||
| 0eb32e8b1d | |||
| fa589a198b | |||
| 18ac0f98fa | |||
| 91e95b1e4d | |||
| bae40fc930 | |||
| 31f2bdb84f | |||
| e692bff2bc | |||
| aae5b4cdd6 | |||
| d6c0722796 | |||
| d677b7055c | |||
| ae3ed4a872 | |||
| 3a1a43d014 | |||
| fef4dc717f | |||
| 9a6e1157a7 | |||
| a88a360450 | |||
| a5dc51168e | |||
| 00af39d369 | |||
| 602c5e5bd6 | |||
| e99110fdc9 | |||
| 1f15a6bc79 | |||
| eee062be3f | |||
| 0ed8f1413e | |||
| 8652a6db09 | |||
| ee0ab3805c | |||
| ab2a1a7920 | |||
| 3dd3ae24df | |||
| 9c1bb40f1d | |||
| a4ed35c9b9 | |||
| 58b6d931cb | |||
| a8e7b419ac | |||
| 744a6ef3f0 | |||
| ab35d81a5a | |||
| dde412a16f | |||
| dbe355a2d5 | |||
| 4321dbbbc0 | |||
| 03ee61f0a6 | |||
| 3870940a2f | |||
| 8633d59b89 | |||
| 6763a83899 | |||
| ab0f0e0cb0 | |||
| 6f7c8443f9 | |||
| 4ab1cc847d | |||
| ca37a606c6 | |||
| 5bace24371 | |||
| 0cf843e13d | |||
| f6c0f8cbe0 | |||
| c97dd98616 | |||
| 929598418e | |||
| 10a90bb213 | |||
| bd52dde6a7 | |||
| 270e294938 | |||
| 15f2d03d7b | |||
| 6d65bff791 | |||
| a0b2cfbee1 | |||
| 79789a8815 | |||
| 1d99dc93ed | |||
| 04b0d12150 | |||
| 9a243a9b96 | |||
| 1917f19436 | |||
| 563ff9e8fa | |||
| 730d26437b | |||
| cf960d1b2a | |||
| 42c8b232cd | |||
| 9f5fedda06 | |||
| b24239eef9 | |||
| db73af2866 | |||
| d1de83a6d3 | |||
|
|
2391b510a5 | ||
|
|
f3edf62cf7 | ||
| 93bd5379e3 | |||
| 79bda6fab9 | |||
| d87aa9dfd8 | |||
| df75a86518 | |||
| befd2650c8 | |||
| 3d6cb467bf | |||
| 5af2fc09ec | |||
| 1e80fb24db | |||
| 778e12d83e | |||
| a96a15a1d2 | |||
| 1852f755ad | |||
| 5b3dd84fb9 | |||
| a7ceb5f793 | |||
| 4d7508382d | |||
| d4fd71baab | |||
| 51751e6473 | |||
| d8f0974e0f | |||
| e9ece35c2a | |||
| bd1e84d32c | |||
| e61b39b5c8 | |||
| 0f803c2d9c | |||
| b9d4cfde98 | |||
| ca44a005cc | |||
| 974be13f4c | |||
| d4397cbe10 | |||
| 929f31cce9 | |||
| 21ef1f2570 | |||
| 73b957d911 | |||
| 4a63b583b7 | |||
| fc36f98450 | |||
| 7173f4dc12 | |||
| 384aa6ac43 | |||
| 994885b2aa | |||
| 2a1a275511 | |||
| 9d68db953f | |||
| 8ddfb33eab | |||
| 7055ce6acd | |||
| 855a67d612 | |||
| be2ae8b07e | |||
| 8d6ac5ef6e | |||
| 5c77b88654 | |||
| 47c9441781 | |||
| 0d05c1a4a4 | |||
| befd701678 | |||
| 00115c79f0 | |||
| 2c9f99e45d | |||
| 4df2232bbd | |||
| 7b252335cc | |||
| 373883fb48 | |||
| e175c3d8f1 | |||
| 529ce8506a | |||
| 3de8b7fde3 | |||
| 30841fbfb1 | |||
| ee406308eb | |||
| 6ce24ce777 | |||
| 0ce3b5adaa | |||
| 90eb298797 | |||
| 405832d049 | |||
| 350c251513 | |||
| eb73f9cd32 | |||
| cd806da576 | |||
| a7e7fb1f16 | |||
| 3083e4b579 | |||
| 0171107d41 | |||
| a28b52da9a | |||
| 045b50fefa | |||
| d36aa14be8 | |||
| 0b0c9ba3bd | |||
| 1bc6c40d05 | |||
| f8f57d7e37 | |||
| 5f30e1154a | |||
| 3e414c1572 | |||
| beb8a36ff2 | |||
| da411b0f48 | |||
| 7fb32ba349 | |||
| 56bf6f44ff | |||
| 48b6c94b4a | |||
| 86c789a7f9 | |||
| bec21647d4 | |||
| 7ad4353fd4 | |||
| 9108f9419c | |||
| 26aa5034f1 | |||
| 48286e80e0 | |||
| 8225ec7a9b | |||
| 82aabacaca | |||
| a21d24f09f | |||
| 7f64f169ca | |||
| 850e685d8d | |||
| 1dbefd5457 | |||
| d1d1302d55 | |||
| 4157fc6f1d | |||
| f16a1a7084 | |||
| 4a8559fa73 | |||
| 27cafabb22 | |||
| 1062a5ed44 | |||
| fec8b364d6 | |||
| f42f9c2419 | |||
| 25b2ff45bf | |||
| d75c367bf7 | |||
| 567986fa49 | |||
| 28af952343 | |||
| b977f5e182 | |||
| f6f0421ff3 | |||
| 8713b46d3f | |||
| 8fde10c743 | |||
| 924fa39b34 | |||
| 3a1edb721b | |||
| 87688b65da | |||
| 231fcb6d6f | |||
| 44cddc5020 | |||
| f4296f2d9e | |||
| 5b44a3e619 | |||
| c462f50152 | |||
| f90d753d13 | |||
| 5299f3d32e | |||
| 5a0aaa2ff2 | |||
| 27c1d972b3 | |||
| 28f95f0d72 | |||
| 0c3ae5d33d | |||
| 2a7dd2d4a0 | |||
| 6e96ec42e8 | |||
| a35a732b95 |
2310
.bb-sp-changes.json
Normal file
2310
.bb-sp-changes.json
Normal file
File diff suppressed because it is too large
Load Diff
1
.bb-tok
Normal file
1
.bb-tok
Normal file
@@ -0,0 +1 @@
|
||||
eyJ0eXAiOiJKV1QiLCJub25jZSI6IlNzRUhBcVJSVzVzOFYwTzBaVVBUdnZGZjRnVXl2YmRvNjFiVklhYjJNbDgiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFGa21LVkZjLTRXVjZzWENCdk5aa1hJNTA1WSIsImtpZCI6ImFGa21LVkZjLTRXVjZzWENCdk5aa1hJNTA1WSJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8xOWE1NjhlOC05ZTg4LTQxM2ItOTM0MS1jYmMyMjRiMzkxNDUvIiwiaWF0IjoxNzgzMDQ4NDQwLCJuYmYiOjE3ODMwNDg0NDAsImV4cCI6MTc4MzA1MjM0MCwiYWNycyI6WyJwZmRyIl0sImFpbyI6ImsyRmdZQWhtcnMxdW5yZlo2TmVTNUxmbDNzNkZNNXA0YnNoN1RuNGhmc1hJZkhWYjkzMEEiLCJhcHBfZGlzcGxheW5hbWUiOiJDb21wdXRlckd1cnUgLSBUZW5hbnQgQWRtaW4iLCJhcHBpZCI6IjcwOWU2ZWVkLTA3MTEtNDg3NS05YzQ0LTJkMzUxOGM0NzA2MyIsImFwcGlkYWNyIjoiMiIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzE5YTU2OGU4LTllODgtNDEzYi05MzQxLWNiYzIyNGIzOTE0NS8iLCJpZHR5cCI6ImFwcCIsIm9pZCI6IjdhMTk5YjExLTk3ZmItNGU2NS05MTdkLWY4ZDI5YTUzYmE0OSIsInJoIjoiMS5BVVlBNkdpbEdZaWVPMEdUUWN2Q0pMT1JSUU1BQUFBQUFBQUF3QUFBQUFBQUFBQUFBQUJHQUEuIiwicm9sZXMiOlsiUG9saWN5LlJlYWRXcml0ZS5Db25kaXRpb25hbEFjY2VzcyIsIlVzZXIuUmVhZFdyaXRlLkFsbCIsIlNlY3VyaXR5RXZlbnRzLlJlYWQuQWxsIiwiVXNlckF1dGhlbnRpY2F0aW9uTWV0aG9kLlJlYWRXcml0ZS5BbGwiLCJBcHBsaWNhdGlvbi5SZWFkV3JpdGUuQWxsIiwiRGlyZWN0b3J5LlJlYWRXcml0ZS5BbGwiLCJTaXRlcy5SZWFkV3JpdGUuQWxsIiwiQXBwUm9sZUFzc2lnbm1lbnQuUmVhZFdyaXRlLkFsbCIsIlJvbGVNYW5hZ2VtZW50LlJlYWRXcml0ZS5EaXJlY3RvcnkiLCJQb2xpY3kuUmVhZC5BbGwiLCJTaXRlcy5GdWxsQ29udHJvbC5BbGwiXSwic3ViIjoiN2ExOTliMTEtOTdmYi00ZTY1LTkxN2QtZjhkMjlhNTNiYTQ5IiwidGVuYW50X3JlZ2lvbl9zY29wZSI6Ik5BIiwidGlkIjoiMTlhNTY4ZTgtOWU4OC00MTNiLTkzNDEtY2JjMjI0YjM5MTQ1IiwidXRpIjoiRFE2UE4wWlRqMEdSMDhWeEhhUWJBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiYjFiZTFjM2UtYjY1ZC00ZjE5LTg0MjctZjZmYTBkOTdmZWI5IiwiMDk5N2ExZDAtMGQxZC00YWNiLWI0MDgtZDVjYTczMTIxZTkwIl0sInhtc19hY2QiOjE3NzY3MDg0MDEsInhtc19hY3RfZmN0IjoiOSAzIiwieG1zX2Z0ZCI6IjZuYTJJRXF0MWliaVd5emFrbzdzM0VHbEdnbm1JRlBUSjdFWUZTcEZPcjBCZFhOemIzVjBhQzFrYzIxeiIsInhtc19pZHJlbCI6IjEwIDciLCJ4bXNfcGZ0ZXhwIjoxNzgzMTM4NzQwLCJ4bXNfcmQiOiIwLjQyTGxZQkppZEJNUzRXQVZFcEJ6U3p5M29MSEZaN2RYdGFuWExOTmNJUkVPVGlHQnRJOWVVdy1lZXVrNVFldzVjLU4ybHM5Q0lod2NRZ0xNREJCd0FFb0xpWEJ3Q3dsc2VTRDdOZHpmN3VRaVNha1duNWF0QWdBIiwieG1zX3N1Yl9mY3QiOiIzIDkiLCJ4bXNfdGNkdCI6MTU4MTAyMjkyNywieG1zX3RudF9mY3QiOiIzIDE0In0.Rfz02nCkcm5iJ19EQVEFhZEcBf_xdd6kXTck-paz5UVgRhSWE8TDPfF9eliB38OZ59bRJdkY6TdtUNrlAAHhJxHs8Hxq6vyYJwKAhzfD-XYntD00Mas1yEe1pobc4vjLPrNYl5ie2KIZvSpmvUc97Lvf5juRueRbvUh9k9QgVwKfGBCHVXirBv9Bekhx4XUraskE-UvROmDvf5mi1DtkskMIC4-Xutz6gFTbJDj-uwTtI2vZa9dQMsVJfMpmq6pWD5lNrIm1tk6LMMIBVA00h3ue5hIt7v5IggvFFqk2JlacMnElQPNkgpwaMJTdcwrVEuonVZuKpv3iYZJU8MtCcg
|
||||
10
.bk.ps1
Normal file
10
.bk.ps1
Normal file
@@ -0,0 +1,10 @@
|
||||
$b='C:\ProgramData\acg-backup'
|
||||
'--- COMPLETE.txt ---'
|
||||
if (Test-Path (Join-Path $b 'COMPLETE.txt')) { Get-Content (Join-Path $b 'COMPLETE.txt') } else { 'absent' }
|
||||
'--- dir listing ---'
|
||||
Get-ChildItem $b | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize | Out-String
|
||||
'--- rclone.log tail ---'
|
||||
Get-Content (Join-Path $b 'rclone.log') -Tail 25
|
||||
'--- rclone process ---'
|
||||
$p = Get-Process rclone -ErrorAction SilentlyContinue
|
||||
if ($p) { 'RUNNING pid=' + $p.Id } else { 'not running' }
|
||||
7
.chk.ps1
Normal file
7
.chk.ps1
Normal file
@@ -0,0 +1,7 @@
|
||||
Import-Module BitsTransfer
|
||||
$j = Get-BitsTransfer -Name 'Win11ARM64ISO' -AllUsers -ErrorAction SilentlyContinue
|
||||
if ($j) { 'state=' + $j.JobState + ' bytes=' + $j.BytesTransferred + '/' + $j.BytesTotal }
|
||||
else { 'NO JOB' }
|
||||
$f = Get-Item 'C:\Users\howar\Desktop\Win11_25H2_English_Arm64_v2.iso' -ErrorAction SilentlyContinue
|
||||
if ($f) { 'file present size=' + $f.Length }
|
||||
'uptime-min: ' + [math]::Round(((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalMinutes,0)
|
||||
8
.chk2.ps1
Normal file
8
.chk2.ps1
Normal file
@@ -0,0 +1,8 @@
|
||||
Import-Module BitsTransfer
|
||||
'--- all BITS jobs (AllUsers) ---'
|
||||
Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Select-Object DisplayName,JobState,BytesTransferred,BytesTotal,OwnerAccount | Format-Table -AutoSize | Out-String
|
||||
'--- desktop files ---'
|
||||
Get-ChildItem 'C:\Users\howar\Desktop' -Filter '*.iso*' -Force -ErrorAction SilentlyContinue | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize | Out-String
|
||||
'--- tmp BITS file? ---'
|
||||
Get-ChildItem 'C:\Users\howar\Desktop' -Filter 'BIT*' -Force -Hidden -ErrorAction SilentlyContinue | Select-Object Name,Length | Format-Table -AutoSize | Out-String
|
||||
'uptime-min: ' + [math]::Round(((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalMinutes,0)
|
||||
@@ -31,6 +31,27 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
||||
|
||||
## Key rules (always)
|
||||
- **NO EMOJIS.** Use ASCII markers: `[OK]` `[ERROR]` `[WARNING]` `[INFO]` `[CRITICAL]`.
|
||||
- **Skill-first — if a skill/command covers the task, USE IT; never hand-roll the API.**
|
||||
When a request maps to an installed skill or slash-command, INVOKE THAT SKILL instead of
|
||||
improvising raw `curl`/API calls from memory. The skill encodes the correct payload shape,
|
||||
validation, attribution, and preview gates; free-handing the API is exactly how malformed
|
||||
records (e.g. Syncro tickets/invoices) reach a human for cleanup. **Syncro billing/invoicing
|
||||
ALWAYS runs through `/syncro` (or `/syncro-emergency-billing`) — no exceptions.** Same for
|
||||
other covered domains: credentials → `vault`, RMM actions → `/rmm` (+ `rmm-search` to find a
|
||||
host), M365 → `remediation-tool`, etc. Knowing the API is NOT a reason to bypass the skill —
|
||||
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`).
|
||||
- **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
|
||||
have to name skill A/B/C. Standing gates (run the check-skill BEFORE declaring done): code edits
|
||||
→ `/code-review` + `/simplify` (+ `/security-review` if it touches auth/creds/security); anything
|
||||
outbound/client-facing → `impeccable` / `stop-slop`; Syncro/vendor work → its skill's own
|
||||
preview+verify gate; wiki/memory writes → their lint (`wiki-lint`/`memory-dream`). **Calibrate to
|
||||
stakes** (a typo fix doesn't need a full review) and **use Ollama Tier-0** for the cheap
|
||||
classify/prose passes so this stays low-token. The full request→doing-skill→check-skill map is
|
||||
`.claude/SKILL_ROUTING.md` — read it on demand when a request maps to a skill or work nears done.
|
||||
- **Credentials — capture, vault, document (ALWAYS).** ANY credential that surfaces in a
|
||||
session — one the user pastes, one you create/rotate, one you discover in a log/config — you
|
||||
MUST immediately store it in the SOPS vault **via the `vault` skill** (the canonical path —
|
||||
|
||||
1
.claude/MEMORY.md
Normal file
1
.claude/MEMORY.md
Normal file
@@ -0,0 +1 @@
|
||||
- [PS5.1 -Headers $H empty-auth quirk](.claude/memory/ps51_invoke_restmethod_headers_variable.md) - build Graph auth headers inline per call under RMM powershell -File; capture Graph error bodies
|
||||
103
.claude/SKILL_ROUTING.md
Normal file
103
.claude/SKILL_ROUTING.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Skill Routing Map
|
||||
|
||||
> On-demand reference for the CORE **Skill-first** + **Definition of Done** rules
|
||||
> (`.claude/CLAUDE.md`). Read this when a request maps to a skill, or when work nears "done"
|
||||
> and needs its check-skill. You infer the right skills from the request + expected end result —
|
||||
> the user should NOT have to name them. **Calibrate to stakes** (a typo fix needs no full review)
|
||||
> and **use Ollama Tier-0** for the cheap classify/prose passes so this stays low-token.
|
||||
|
||||
## How to use this (every substantive request)
|
||||
1. **Route the action** → pick the *doing-skill* for what's being asked (tables below).
|
||||
2. **Do the work** through that skill (not hand-rolled API).
|
||||
3. **Gate the result** → before saying "done," run the *check-skill(s)* matching the work-type.
|
||||
4. Only declare done after the gate passes. If you skip a gate for stakes reasons, say so.
|
||||
|
||||
---
|
||||
|
||||
## Doing-skills (request → the skill that DOES it)
|
||||
|
||||
| Request signal | Skill |
|
||||
|---|---|
|
||||
| Ticketing, billing, invoicing, customers, scheduling, appointments | `/syncro` (after-hours/emergency billing → `/syncro-emergency-billing`). Autotask ONLY on an explicit "in Autotask" request. |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` |
|
||||
| VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` |
|
||||
| Email security — Mailprotector/CloudFilter held/quarantined mail, allow/block rules | `mailprotector` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Inter-session messaging, fleet todos, resource locks, coord status | `coord` |
|
||||
| Git / Gitea / submodules / sync health | `gitea` (or `/sync`, `/scc`, `/save` for session ops) |
|
||||
| Build/verify GuruRMM agent/server/dashboard, pre-merge check | `gururmm-build` |
|
||||
| Capture an RMM feature idea | `/feature-request`; GuruConnect feature → `gc-feature-request` |
|
||||
| Compile session logs/Syncro into wiki | `/wiki-compile` |
|
||||
| Create a new skill or slash command | `skill-creator` |
|
||||
| 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` |
|
||||
|
||||
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
|
||||
|
||||
### Backups (wiki-first routing)
|
||||
|
||||
A customer can back up through **any** of several backends, so a backup request is a
|
||||
two-step route: **first find out which backend THIS customer uses, then use that skill.**
|
||||
Never assume B2/MSP360 — several clients are on Seafile, ownCloud, or Datto Workplace.
|
||||
|
||||
1. **Identify the backend for this customer (don't guess).** Read the client wiki
|
||||
(`wiki/clients/<slug>.md`) and the backup map (`projects/gps-rmm-audit/backup-map.md`) —
|
||||
both record each customer's real destination. If it isn't recorded, confirm with
|
||||
`msp360 monitoring --company <name>` plus the per-backend inventory skills, then write the
|
||||
answer back to the client wiki.
|
||||
2. **Route to that backend's skill:**
|
||||
|
||||
| Need | Skill |
|
||||
|---|---|
|
||||
| Is X backing up? status / last run / failures / stale plans (MSP360 + B2 Cloud plans) | `msp360` — **authoritative**, check here not bucket contents |
|
||||
| Who backs up to Seafile / ownCloud / Datto Workplace (inventory + endpoint detection) | `seafile` / `owncloud` / `datto-workplace` |
|
||||
| Storage / bucket / cost on the destination side | `b2` (Backblaze) |
|
||||
| **Set up / provision a backup** — create account/user, quota, library, license, destination | the **target backend's** skill: `msp360` (MBS user/company/license), `seafile`, `owncloud` (all writes gated `--confirm`); `b2` for a new bucket/key |
|
||||
|
||||
- **Which machine gets backed up is a per-client decision** — report mismatches (billed vs
|
||||
running), don't provision jobs off a billing count. See [[feedback_backup_targets_never_guessed]].
|
||||
- Full per-customer destination + health map: `projects/gps-rmm-audit/backup-map.md`;
|
||||
how backup-status works: memory `[[reference_msp360_backup_monitoring]]`.
|
||||
|
||||
---
|
||||
|
||||
## Check-skills (work-type → the gate run BEFORE "done")
|
||||
|
||||
**Code changes** (any edit to source):
|
||||
- `/code-review` — correctness/bugs. `/simplify` — reuse/cleanup. Scale to stakes.
|
||||
- `/security-review` — REQUIRED when the change touches auth, credentials, security, or production risk.
|
||||
- `test-driven-development` — when implementing a feature/bugfix (test first). `/verify` or `run` — confirm real behavior, not just tests.
|
||||
- `rust-skills` — Rust. `inject-standards` — apply project coding standards. `human-flow`/`impeccable` — interactive UI.
|
||||
- GuruRMM specifically: `gururmm-build` verify before merge-to-main (merge IS the deploy).
|
||||
|
||||
**Outbound / client-facing** (anything sent to a client or vendor — Syncro comment, email, DM, doc):
|
||||
- `impeccable` — polish + correctness before it leaves (standing rule). `stop-slop` — strip AI-slop tone.
|
||||
- Internal-only drafts are exempt.
|
||||
|
||||
**Syncro / vendor actions**:
|
||||
- The skill's own **preview + explicit-confirm gate** is the check — show the full payload, wait for yes, then post. Never post-then-report. (Syncro: no API edit/delete after the fact.)
|
||||
|
||||
**Docs / wiki / memory writes**:
|
||||
- Wiki articles → `wiki-lint`. Memory store edits → `memory-dream` checks (index/backlinks/dupes).
|
||||
|
||||
---
|
||||
|
||||
## Stakes calibration (don't burn tokens)
|
||||
- Trivial/mechanical (typo, comment, rename, one-line doc) → skip the heavy review; a quick self-check is enough. Say you skipped and why.
|
||||
- Normal change → the standard gate above.
|
||||
- Security / auth / credential / migration / production / data-loss / outbound-to-client → full gate, bump model one tier, never skip.
|
||||
- Push the cheap parts (classify which skills apply, prose/tone passes) to **Ollama Tier-0**; reserve inherited/opus for the actual judgment.
|
||||
|
||||
See also: `.claude/CLAUDE_EXTENDED.md` (full workflow detail), memories `[[feedback_skill_first_routing]]`, `[[feedback_impeccable_on_outbound]]`, `[[feedback_syncro_preview_mandatory]]`, `[[feedback_calibrate_effort_to_stakes]]`.
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Read and send mail for an Arizona Computer Guru mailbox via Microsoft Graph, using the dedicated **ComputerGuru Mailbox** app (`1873b1b0-3377-485c-a848-bae9b2f8f1f5`). Defaults to the mailbox of the user running it (from `identity.json`).
|
||||
|
||||
> **Mail path (working — repointed 2026-06-17).** `/mailbox` uses the dedicated single-tenant **ComputerGuru Mailbox** app (`1873b1b0-3377-485c-a848-bae9b2f8f1f5`; vault `msp-tools/computerguru-mailbox.sops.yaml`; Mail.ReadWrite + Mail.Send + Contacts.ReadWrite; azcomputerguru.com only). Tokens come from the suite tool: `bash .claude/skills/remediation-tool/scripts/get-token.sh azcomputerguru.com mailbox` (cert-preferred, secret fallback, 55-min cache). This **replaces the deleted `fabb3421`** (Claude-MSP-Access), removed from the tenant 2026-06-14 — it returns **AADSTS700016**; do NOT reintroduce it. The mailbox app's service principal is **disabled when idle**: on a token 401 "account is disabled", enable the SP, then retry.
|
||||
> **Inbox-rule capability (added 2026-06-23).** The app now also holds **`MailboxSettings.ReadWrite`** (Graph app role `6931bccd-447a-43d1-b442-00a195474933`, admin-consented via the Tenant Admin app `709e6eed`). This is what enables creating/modifying mailbox **inbox rules** (`messageRules`) — `Mail.ReadWrite` alone returns 403 on that endpoint. First use: a "keep DMARC reports in Inbox" rule on `rua@azcomputerguru.com`.
|
||||
>
|
||||
> **Mail path (working — repointed 2026-06-17).** `/mailbox` uses the dedicated single-tenant **ComputerGuru Mailbox** app (`1873b1b0-3377-485c-a848-bae9b2f8f1f5`; vault `msp-tools/computerguru-mailbox.sops.yaml`; Mail.ReadWrite + Mail.Send + Contacts.ReadWrite + MailboxSettings.ReadWrite; azcomputerguru.com only). Tokens come from the suite tool: `bash .claude/skills/remediation-tool/scripts/get-token.sh azcomputerguru.com mailbox` (cert-preferred, secret fallback, 55-min cache). This **replaces the deleted `fabb3421`** (Claude-MSP-Access), removed from the tenant 2026-06-14 — it returns **AADSTS700016**; do NOT reintroduce it. The mailbox app's service principal is **disabled when idle**: on a token 401 "account is disabled", enable the SP, then retry.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -25,7 +27,7 @@ Microsoft Graph access to ACG's own mailboxes (azcomputerguru.com tenant). Readi
|
||||
|
||||
## API Configuration
|
||||
|
||||
- **App:** ComputerGuru Mailbox (dedicated single-tenant Graph app), `client_id = 1873b1b0-3377-485c-a848-bae9b2f8f1f5` (Mail.ReadWrite + Mail.Send + Contacts.ReadWrite, azcomputerguru.com only). Replaces the deleted `fabb3421`.
|
||||
- **App:** ComputerGuru Mailbox (dedicated single-tenant Graph app), `client_id = 1873b1b0-3377-485c-a848-bae9b2f8f1f5` (Mail.ReadWrite + Mail.Send + Contacts.ReadWrite + MailboxSettings.ReadWrite, azcomputerguru.com only). Replaces the deleted `fabb3421`.
|
||||
- **Tenant:** `azcomputerguru.com`
|
||||
- **Token:** acquire via the suite tool — `bash .claude/skills/remediation-tool/scripts/get-token.sh azcomputerguru.com mailbox` (cert-preferred, secret fallback, 55-min cache in `/tmp/remediation-tool/<tenant>/mailbox.jwt`). Do NOT roll your own `client_credentials` here. Credential vault entry: `msp-tools/computerguru-mailbox.sops.yaml`.
|
||||
- **SP idle-toggle:** the mailbox app's service principal is disabled when idle. On a token 401 "account is disabled", enable the SP, then retry.
|
||||
|
||||
@@ -159,6 +159,24 @@ Use `python` only when explicitly writing a Python script. Use `script` for save
|
||||
|
||||
**VALID `command_type` values ONLY: `shell`, `powershell`, `python`, `script`, `claude_task` (plus alias `cmd` → shell = cmd.exe).** The agent deserializes `command_type` into a Rust enum; an UNKNOWN value (e.g. a made-up type) fails the agent's whole-message JSON parse and the command is **silently dropped — no ack, no result, no error** — which is indistinguishable from a network black-hole and has caused a long mis-diagnosis. On Windows: `powershell` runs powershell.exe (UTF-8 output fixed in-agent); `shell` or `cmd` runs cmd.exe. If a dispatched command sits un-acked forever, FIRST suspect an invalid `command_type` before chasing the network. (Newer agents NAK an unparseable command so it fails fast with a clear stderr instead of black-holing.)
|
||||
|
||||
### Quote-safe dispatch for real scripts (PREFERRED for anything with quotes/UNC/$)
|
||||
|
||||
Any PowerShell payload containing embedded double-quotes, UNC `\\` paths, or `$`
|
||||
that must survive literally should NOT be inlined into the JSON dispatch — the
|
||||
RMM->cmd.exe layer strips/mangles them (see memory `feedback_windows_quote_stripping`).
|
||||
Write the script to a file (with the Write tool — bash heredocs collapse `\\`),
|
||||
then dispatch it byte-exact via `-EncodedCommand`:
|
||||
|
||||
```bash
|
||||
bash .claude/scripts/ps-encoded.sh rmm "$AGENT_ID" script.ps1 --timeout 120 [--user-session]
|
||||
# or print a paste-safe one-liner for ScreenConnect / plink:
|
||||
bash .claude/scripts/ps-encoded.sh encode script.ps1
|
||||
```
|
||||
|
||||
Size limit: the agent fails on ~7KB command bodies and encoding inflates ~2.67x,
|
||||
so keep scripts under ~2KB raw (the helper warns at 4KB encoded, refuses at 6KB).
|
||||
Inline dispatch below remains fine for simple quote-free commands.
|
||||
|
||||
### Basic dispatch
|
||||
|
||||
```bash
|
||||
|
||||
@@ -70,34 +70,40 @@ Reference: Invoice 67594 (VWP, 2026-05-12), Ticket #32269.
|
||||
|
||||
### Scenario B — Non-Block / Direct Billing Customer
|
||||
|
||||
Use a **single emergency labor product** for the full hours worked:
|
||||
Use a **single emergency labor product** for the full hours worked. All delivery channels use product 26184 — the rate varies by how work was delivered:
|
||||
|
||||
| Customer type | Product | Product ID | Rate |
|
||||
| Delivery channel | Product | Product ID | Rate |
|
||||
|---|---|---|---|
|
||||
| Business | Labor - Emergency or After Hours Business | 26184 | $262.50/hr |
|
||||
| Remote | Labor - Emergency or After Hours Business | 26184 | $225.00/hr |
|
||||
| In-Shop | Labor - Emergency or After Hours Business | 26184 | $225.00/hr |
|
||||
| Onsite | Labor - Emergency or After Hours Business | 26184 | $262.50/hr |
|
||||
|
||||
Residential rates are legacy — ACG no longer bills residential. Do not use product 42584.
|
||||
|
||||
**Example:** 4 hours of emergency business work, direct billing:
|
||||
- Line 1: `Labor - Emergency or After Hours Business` (26184) — 4.0 hr
|
||||
**IMPORTANT:** Product 26184's `price_retail` from the live API returns $262.50 (the onsite rate). For remote and in-shop work, you MUST explicitly pass `"price_retail": 225.00` — do NOT use the live-fetched rate blindly for those channels.
|
||||
|
||||
Reference: Ticket #32188 (VWP, direct billing, 2026-04-22).
|
||||
**Example:** 4 hours of emergency in-shop work, direct billing:
|
||||
- Line 1: `Labor - Emergency or After Hours Business` (26184) — 4.0 hr @ $225.00
|
||||
|
||||
Reference: Ticket #32188 (VWP, direct billing, 2026-04-22). Rates confirmed by Winter 2026-06-23.
|
||||
|
||||
---
|
||||
|
||||
## Standard Labor Products (Reference)
|
||||
|
||||
Emergency business rate is **$262.50/hr** (product 26184) — used for all emergency/afterhours business work regardless of remote vs onsite. Residential rates are legacy and not in use.
|
||||
Emergency rates use product 26184 for all channels, but the `price_retail` differs by delivery method — do NOT blindly use the live API rate for remote/in-shop (the API returns the onsite default of $262.50). Residential rates are legacy and not in use.
|
||||
|
||||
**Always fetch `price_retail` from `GET /api/v1/products/{id}` before billing non-block customers. Never use a hardcoded rate.**
|
||||
**For non-emergency work:** fetch `price_retail` from `GET /api/v1/products/{id}` before billing. For emergency work: use the rates below (confirmed by Winter 2026-06-23).
|
||||
|
||||
| Service type | Product | Product ID | Live Rate | Notes |
|
||||
| Service type | Product | Product ID | Rate | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Remote Business | Labor - Remote Business | 1190473 | $150.00/hr | Non-block cash billing |
|
||||
| Onsite Business | Labor - Onsite Business | 26118 | $175.00/hr | |
|
||||
| In-Shop Business | Labor - In Shop Business | 573881 | $150.00/hr | |
|
||||
| Block/Prepaid (any type) | Labor - Remote Business | 1190473 | $0.00 | Price = $0; draws from block in hours |
|
||||
| Emergency/Afterhours Business | Labor - Emergency or After Hours Business | 26184 | $262.50/hr | All business emergency — remote and onsite |
|
||||
| Emergency Remote | Labor - Emergency or After Hours Business | 26184 | **$225.00/hr** | Override price_retail — API default is wrong for this channel |
|
||||
| Emergency In-Shop | Labor - Emergency or After Hours Business | 26184 | **$225.00/hr** | Override price_retail — API default is wrong for this channel |
|
||||
| Emergency Onsite | Labor - Emergency or After Hours Business | 26184 | **$262.50/hr** | API default is correct for onsite |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
|
||||
|
||||
**Emergency/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/<id>` and read `prepay_hours`. Emergency = time-and-a-half (×1.5), applied ONCE — never bill a separate regular + emergency line for the same hours. **No prepaid (`prepay_hours == 0`):** `26184` at qty = actual hours; set `price_retail` by delivery channel — **Onsite $262.50** (175×1.5, 26184's default), **Remote / In-Shop $225** (150×1.5, override price_retail). The rate carries the 1.5×; do NOT also ×1.5 the qty. **Prepaid (`prepay_hours > 0`):** still use `26184`, at qty = actual hours **× 1.5** (premium goes in the quantity since prepaid debits by quantity; invoice nets $0, block debits hours×1.5). e.g. 1.5 emergency hrs prepaid → `26184` @ 2.25. (Rule updated 2026-05-27 by Mike: prepaid emergency uses `26184`, NOT the old `26118`×1.5 — keeps the line labeled emergency + mapping right in QuickBooks. Original ×1.5-not-additive lesson: #32203 Desert Auto Tech 2026-04-23, Winter.)
|
||||
|
||||
**`prepay_hours` is ONLY reliable from `GET /customers/{id}` — the customer SEARCH/LIST endpoint lies.** `GET /customers?query=...` (and any list endpoint) returns `prepay_hours: null` (or stale) even when the customer HAS a block. **NEVER read `prepay_hours` from a search/list result, and NEVER assert "no prepaid block" / "real charge" / a dollar total in a preview built from search data.** Before ANY billing preview or decision that mentions prepay, do a full `GET /customers/{id}` and read `.customer.prepay_hours` from THAT response. If you only have search data, fetch the full record first — do not guess. The billing-flow Step-1 GET is mandatory and must happen BEFORE the preview, not just before the invoice. (Recurring miss flagged by Mike 2026-06-23: previews repeatedly said "$300, no block," then the block surfaced at invoice time and netted $0 — Dataforth, Grabb & Durando #32455.)
|
||||
|
||||
**Prepaid customers — ALL billing (not just emergency):** `GET /customers/<id>` → `prepay_hours` before creating ANY invoice for a prepaid customer. When you bill a prepaid customer using a billable labor product (remote / onsite / in-shop / web), Syncro automatically deducts from their prepay block and the invoice total shows $0.00. The line item name is annotated "- Applied X Prepay Hours". This is correct behavior — do NOT treat a $0.00 invoice as an error. Verify the deduction by re-fetching `customer.prepay_hours` after invoicing and confirming it dropped by `quantity`.
|
||||
|
||||
**`9269129` (Labor - Prepaid Project Labor) is EXEMPT — it does NOT deduct from prepay blocks:** Despite the name, this product is categorized as Exempt Labor at $0.00 and contains no prepay-deduction logic. Billing a prepaid customer with this product results in a $0.00 invoice AND no block decrement — silent accounting drift. Discovered 2026-05-04 (see `feedback_syncro_labor_type.md`). NEVER use `9269129` for normal or prepaid work. Only use it if explicitly directed. The correct approach for prepaid customers is a billable labor product matching the delivery channel (remote / onsite / in-shop / web).
|
||||
@@ -53,6 +55,10 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
|
||||
|
||||
**Always pass `"taxable": false` explicitly on labor line items.** Labor products are configured with `taxable: false` in Syncro, but `add_line_item` via API does not inherit the product's taxable setting — it posts the line item as `taxable: true` regardless. Always include `"taxable": false` in the payload to match the product's configured value.
|
||||
|
||||
**`product_category` is a CONTROLLED VOCABULARY — never invent one, never invoice a line blank.** Syncro categories are an admin-defined set; the CATEGORY column on every invoice line drives QuickBooks item mapping and revenue reporting. A blank category = an uncategorized line that breaks both. Two hard rules:
|
||||
- **Never make up a category string.** Use ONLY a value that already exists in the tenant. To see the live set, enumerate distinct `product_category` across products (do NOT hardcode — it changes): `for p in $(seq 1 N); do curl -s "${BASE}/products?per_page=100&page=$p&api_key=${API_KEY}"; done | jq -r '.products[].product_category' | sort -u`. Known good values include `Labor`, `Software`, `Hardware`, `Product`, `Service`/`Services`, `Subscriptions`, `Contract`, `Discount`, `Exempt Labor`, `Prepay Hours`, `Other`, `Deposit`, `Default`. If none clearly fits, **ASK — do not guess**.
|
||||
- **Never invoice a line whose product has `product_category: null`.** Before billing, `GET /products/<id>` and check `.product.product_category`. If it is `null`/blank, the product is mis-configured — STOP and flag it (a Windows/OS/software item → `Software`; a physical item → `Hardware`). Do not silently push the line out uncategorized. (Incident 2026-06-30, Cascades #32466/#32474 invoices 67887/67890: "Windows Pro Upgrade" product 23571919 has `product_category: null`, so every billed line showed a blank CATEGORY column. Winter corrected the records; the rule is here so the skill catches it next time.)
|
||||
|
||||
**`DELETE /schedules/{id}` destroys the recurring invoice template immediately — no confirmation, no undo.** Past generated invoices are unaffected but future billing stops. The schedule must be recreated manually with all line items if deleted accidentally. NEVER run destructive HTTP method probes against a live customer schedule — use ACG internal account (customer_id 15353550) for any testing. Incident: Russo Law Firm schedule 224454 deleted during API research 2026-05-26; recreated as 509659.
|
||||
|
||||
**Test articles — always prefix the subject/name with `[TEST]`.** Any ticket, estimate, appointment, or schedule created for testing or API research MUST have its subject or name prefixed with `[TEST]` (e.g. `[TEST] Schedule API research`, `[TEST] Estimate - hardware pricing`). This applies regardless of which customer account is used (including the ACG internal test account, customer_id 15353550). Test records must be instantly distinguishable from real customer work at a glance. If a test article was created without the prefix, PUT the subject to add it before continuing.
|
||||
@@ -610,9 +616,9 @@ COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
|
||||
#### Customers
|
||||
|
||||
```bash
|
||||
# Search
|
||||
# Search — returns id/name/email ONLY. Do NOT read prepay_hours from this list (it is null/stale here).
|
||||
curl -s "${BASE}/customers?query=<name>&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]'
|
||||
# Get one
|
||||
# Get one — this is the ONLY trustworthy source of prepay_hours. Always GET this before any billing preview.
|
||||
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}'
|
||||
```
|
||||
|
||||
@@ -668,6 +674,8 @@ JSON
|
||||
- `price_retail` — **must be set explicitly**; Syncro does NOT auto-populate from product rate via API
|
||||
- `taxable` — **must be set explicitly**; always `false` for labor; `true` for taxable hardware
|
||||
|
||||
**Pre-flight every line item's category.** `GET /products/<id>` and confirm `.product.product_category` is non-null and is a real existing category (see the controlled-vocabulary rule above). A `null` category means the line will invoice with a blank CATEGORY column — STOP and flag the product instead of billing it. Never substitute an invented category.
|
||||
|
||||
**Do NOT remove line items after invoicing.** Leave them on the ticket.
|
||||
|
||||
**Labor product IDs** — always fetch `price_retail` live, never hardcode:
|
||||
|
||||
55
.claude/commands/tailscale.md
Normal file
55
.claude/commands/tailscale.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: tailscale
|
||||
description: Manage an ACG-administered Tailscale tailnet via the REST API v2 - list/inspect devices, delete a node, authorize a device, create/list/revoke tagged pre-auth keys. Reads free; writes gated --confirm.
|
||||
---
|
||||
|
||||
# /tailscale — Tailscale tailnet management (REST API v2)
|
||||
|
||||
Thin entry point to the `tailscale` skill. Engine:
|
||||
`.claude/skills/tailscale/scripts/tailscale-api.sh`. Full reference:
|
||||
`.claude/skills/tailscale/SKILL.md`. Doctrine (per-client tailnets, tagged pre-auth
|
||||
keys, offboarding): `wiki/patterns/tailscale-client-management.md`.
|
||||
|
||||
Read-only by default; every device delete/authorize and auth-key create/delete is
|
||||
**gated behind `--confirm`** and previews first. ADMIN rights: add + delete.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
TS="bash .claude/skills/tailscale/scripts/tailscale-api.sh"
|
||||
|
||||
# Reads (no confirm)
|
||||
$TS status # auth check + tailnet + device count
|
||||
$TS devices [--json] # list devices
|
||||
$TS device <name|id|100.x> [--json] # device detail
|
||||
$TS keys [--json] # list auth keys
|
||||
|
||||
# Writes (GATED — preview without --confirm, act with it)
|
||||
$TS create-key --tag tag:<client> --reusable --preauth [--ephemeral] \
|
||||
[--expiry-days N] [--desc "..."] --confirm
|
||||
$TS delete-key <keyId> --confirm # revoke an auth key
|
||||
$TS authorize <name|id|100.x> --confirm # approve a device
|
||||
$TS delete-device <name|id|100.x> --confirm # remove a node
|
||||
|
||||
# Point at a specific per-client tailnet's vault entry
|
||||
$TS devices --vault tailscale/roberts.sops.yaml
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Vault-first auth.** Credentials come from `tailscale/api-access.sops.yaml` (OAuth
|
||||
client preferred, `credentials.api_key` token fallback); tailnet defaults to `-`. Never
|
||||
hardcode a token. Provisioning commands are in the skill doc.
|
||||
2. **Read before write.** Device ops resolve a name/100.x to the stable id first; an
|
||||
**ambiguous match STOPS** (lists candidates, does nothing) — pass a specific id.
|
||||
3. **Gated writes.** No `--confirm` = preview + exit, no change. Deleting a node drops it
|
||||
off the tailnet; revoking a key blocks future enrollments with it.
|
||||
4. **Per-client isolation.** One tailnet per client, one vault entry per tailnet. Confirm
|
||||
the `--vault`/tailnet before any write.
|
||||
5. **Key secrets → vault, never chat/commit.** `create-key` prints the secret once; store
|
||||
it via `/vault` immediately.
|
||||
6. **Bot alert after every write.** Successful writes auto-post a `[TAILSCALE] ...` line to
|
||||
Discord (soft-fail); reads do not.
|
||||
|
||||
Use this to *drive* the tailnet; use `/rmm` to push the tagged pre-auth key onto client
|
||||
machines (`tailscale-client-enroll.ps1`) once a key is minted.
|
||||
@@ -7,17 +7,29 @@ Seed new wiki articles or refresh existing ones from session logs, client docume
|
||||
## Usage
|
||||
|
||||
```
|
||||
/wiki-compile client:<slug> Seed or refresh a client wiki article
|
||||
/wiki-compile client:<slug> --full Force full recompile of existing article (Sonnet synthesis)
|
||||
/wiki-compile client:<slug> UPDATE an existing article (fast, incremental) or seed a new one
|
||||
/wiki-compile client:<slug> --full REBUILD: full re-synthesis from ALL sources (Sonnet, slow)
|
||||
/wiki-compile client:<slug> --syncro Syncro dynamic fields only (hours/tickets) — instant, no LLM
|
||||
/wiki-compile project:<slug> Compile a project wiki article (no Syncro)
|
||||
/wiki-compile system:<slug> Compile a system wiki article (no Syncro)
|
||||
/wiki-compile all Process all missing + stale articles
|
||||
```
|
||||
|
||||
**Mode auto-detection:**
|
||||
- If `wiki/clients/<slug>.md` **does not exist** → **Seed mode** (full synthesis, Sonnet subagent)
|
||||
- If `wiki/clients/<slug>.md` **exists** and no `--full` flag → **Refresh mode** (surgical update of dynamic fields only, no subagent)
|
||||
- `--full` flag → **Full recompile** (Sonnet synthesis, preserves existing Patterns/History)
|
||||
- If `wiki/clients/<slug>.md` **does not exist** → **Seed mode** (full synthesis, Sonnet subagent).
|
||||
- If it **exists** and no flag → **Update mode** (fast, incremental — the default; see below).
|
||||
- `--full` → **Rebuild** (full Sonnet re-synthesis from ALL sources; preserves Patterns/History).
|
||||
- `--syncro` → **Syncro-only refresh** (dynamic fields only; instant, no LLM).
|
||||
|
||||
**Update vs Rebuild — why update is fast (this is the point).** A **Rebuild** (`--full`) reads
|
||||
*every* session log for the client and regenerates the *entire* article with a Sonnet subagent —
|
||||
correct, but slow and expensive, and wasteful when only one thing changed. **Update** reads ONLY
|
||||
the session logs dated after the article's `last_compiled` (usually 1–3 files, often zero) plus the
|
||||
current article, and applies a few **surgical section edits** — new History rows, and targeted
|
||||
edits to any section a new log actually changes — leaving the rest of the article byte-for-byte
|
||||
untouched. Small input + small output + no full-article Sonnet pass = typically many times faster.
|
||||
Reach for `--full` only when the article structure has drifted, sections are stale/wrong, or you
|
||||
want a periodic clean rebuild. For "I just did some work, capture it," use plain update.
|
||||
|
||||
---
|
||||
|
||||
@@ -65,12 +77,21 @@ esac
|
||||
if [ ! -f "$CLAUDETOOLS_ROOT/$ARTICLE_PATH" ]; then
|
||||
MODE="seed"
|
||||
elif [ "$FULL_FLAG" = "--full" ]; then
|
||||
MODE="full"
|
||||
MODE="full" # rebuild — full Sonnet re-synthesis
|
||||
elif [ "$FULL_FLAG" = "--syncro" ]; then
|
||||
MODE="syncro" # Syncro dynamic fields only
|
||||
else
|
||||
MODE="refresh"
|
||||
MODE="update" # default: fast incremental knowledge merge
|
||||
fi
|
||||
|
||||
echo "[INFO] Mode: $MODE | Target: $TARGET_TYPE:$SLUG"
|
||||
|
||||
# For update mode, read the article's last_compiled so Phase 3 can select only newer logs.
|
||||
LAST_COMPILED=""
|
||||
if [ "$MODE" = "update" ]; then
|
||||
LAST_COMPILED=$(sed -n 's/^last_compiled:[[:space:]]*//p' "$CLAUDETOOLS_ROOT/$ARTICLE_PATH" | head -1)
|
||||
echo "[INFO] Update since last_compiled=${LAST_COMPILED:-unknown}"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
@@ -244,6 +265,28 @@ SOURCE_COUNT=$(echo "$ALL_SOURCES" | grep -c '^' || echo 0)
|
||||
echo "[INFO] Found $SOURCE_COUNT source files"
|
||||
```
|
||||
|
||||
**Update mode — narrow to NEW sources only (this is the speedup).** In update mode, do NOT read
|
||||
the full source set. Select only the logs the article has not yet incorporated: a source is "new"
|
||||
if its filename date is **after** `LAST_COMPILED`, **or** it is not already listed in the article's
|
||||
frontmatter `sources:`. Read only those.
|
||||
|
||||
```bash
|
||||
if [ "$MODE" = "update" ]; then
|
||||
# existing sources already folded into the article
|
||||
EXISTING_SRC=$(awk '/^sources:/{f=1;next} /^[^ -]/{f=0} f&&/^[[:space:]]*-/{sub(/^[[:space:]]*-[[:space:]]*/,"");print}' "$CLAUDETOOLS_ROOT/$ARTICLE_PATH")
|
||||
NEW_SOURCES=$(echo "$ALL_SOURCES" | while read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
# (a) not yet in the article's sources list?
|
||||
if ! grep -qxF "$f" <<<"$EXISTING_SRC"; then echo "$f"; continue; fi
|
||||
# (b) filename carries a YYYY-MM-DD newer than last_compiled?
|
||||
d=$(echo "$f" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
|
||||
if [ -n "$d" ] && [ -n "$LAST_COMPILED" ] && [ "$d" \> "$LAST_COMPILED" ]; then echo "$f"; fi
|
||||
done | sort -u | grep -v '^$')
|
||||
NEW_COUNT=$(echo "$NEW_SOURCES" | grep -c '^' || echo 0)
|
||||
echo "[INFO] Update: $NEW_COUNT new source(s) since ${LAST_COMPILED:-unknown}"
|
||||
fi
|
||||
```
|
||||
|
||||
If `SOURCE_COUNT == 0` and no Syncro data: warn and stop.
|
||||
```
|
||||
[ERROR] No session logs and no Syncro data found for '${SLUG}'. Cannot compile.
|
||||
@@ -254,7 +297,40 @@ If `SOURCE_COUNT == 0` and no Syncro data: warn and stop.
|
||||
|
||||
## Phase 4 — Article Generation
|
||||
|
||||
### Refresh Mode (existing article, no --full)
|
||||
### Update Mode (existing article, default) — fast incremental
|
||||
|
||||
Fold only what changed since the last compile. **Do NOT re-synthesize the whole article and do
|
||||
NOT spawn a Sonnet subagent.** Two parts:
|
||||
|
||||
**Part A — Syncro dynamic fields** (the three surgical edits documented under *Syncro-only Refresh*
|
||||
below): hours remaining, Active Work ticket list, and frontmatter (`last_compiled`, `compiled_by`).
|
||||
|
||||
**Part B — Incremental knowledge merge** — run ONLY if `NEW_COUNT > 0` (new logs from Phase 3):
|
||||
1. Read the full text of the `NEW_SOURCES` logs **and the current article**. Do NOT read the full
|
||||
historical log set — that is what makes this fast.
|
||||
2. Apply **targeted edits** for what those new logs actually establish:
|
||||
- **Always:** add one dated row per material change to **History Highlights** (chronological).
|
||||
- **Infrastructure** — add/adjust a row for any new or removed host, IP, service, or key path.
|
||||
- **Access** — add any new vault path or access route (vault path only, never the secret).
|
||||
- **Patterns & Known Issues** — add a genuinely new recurring issue, or mark an existing one
|
||||
resolved if a new log shows it fixed.
|
||||
- Touch **only** the sections a new log changes; leave every other byte of the article intact.
|
||||
3. The delta is small, so **the main agent applies these edits directly** (Edit tool), or delegates
|
||||
only the prose wording to Ollama Tier-0 / `haiku` and reviews it. Follow the same Hard Rules
|
||||
(Syncro authoritative for billing; never inline secrets; never invent vault paths).
|
||||
4. Append the `NEW_SOURCES` paths to frontmatter `sources:` (dedup).
|
||||
|
||||
If `NEW_COUNT == 0`: there is nothing new to fold — Part A (Syncro refresh) is the whole update.
|
||||
|
||||
Emit:
|
||||
```
|
||||
[OK] Update complete for wiki/clients/<slug>.md
|
||||
- New logs folded: <NEW_COUNT> (since <LAST_COMPILED>)
|
||||
- Sections touched: History[, Infrastructure, Access, Patterns] | none (Syncro-only)
|
||||
- Syncro: hours <PREPAY_HOURS>, tickets <TICKET_COUNT>
|
||||
```
|
||||
|
||||
### Syncro-only Refresh (`--syncro`) — instant, no LLM
|
||||
|
||||
Perform surgical updates only. No Ollama call. Three edits:
|
||||
|
||||
@@ -298,7 +374,7 @@ After edits, emit:
|
||||
- Sources: ${SOURCE_COUNT} files tracked
|
||||
```
|
||||
|
||||
### Seed Mode / Full Recompile — Claude Synthesis (Sonnet subagent)
|
||||
### Seed Mode / Rebuild (`--full`) — Claude Synthesis (Sonnet subagent)
|
||||
|
||||
Prepare the synthesis context by reading the most relevant source files. For session logs, read the full content of client-specific logs and the first 200 lines of root session logs (to avoid overwhelming the prompt). For full recompile, also read the existing article.
|
||||
|
||||
@@ -447,4 +523,7 @@ When invoked as `/wiki-compile all`:
|
||||
- **Never invent vault paths.** If a credential is not mentioned in session logs, write "(verify)" in the Access section.
|
||||
- **Never populate Infrastructure tables with placeholder rows.** Only include servers/services that appear in session logs or Syncro assets.
|
||||
- **Syncro contacts are ground truth for the Profile section.** Do not override with session log guesses if the contact name differs.
|
||||
- **Refresh mode never touches Patterns or History.** Those sections require human review or `--full`.
|
||||
- **Syncro-only refresh (`--syncro`) never touches Patterns or History.** It edits dynamic fields only.
|
||||
- **Update mode may ADD to History (always) and may add/adjust Infrastructure, Access, and Patterns**
|
||||
strictly from the NEW logs — it never rewrites or removes existing prose. Wholesale re-synthesis
|
||||
(rewriting existing sections, reconciling contradictions across the full history) is `--full` only.
|
||||
|
||||
55
.claude/hooks/block-tmp-path.sh
Executable file
55
.claude/hooks/block-tmp-path.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse(Bash) hook: block bash commands that WRITE files under /tmp on
|
||||
# Windows (Git Bash / MSYS).
|
||||
#
|
||||
# Why: MSYS bash and the harness's other tools (Write, Python via py launcher)
|
||||
# resolve /tmp to DIFFERENT real directories on Windows, so a file curl/tee
|
||||
# writes to /tmp cannot be read back by the next tool call — the single most
|
||||
# repeated tmp-path friction in errorlog.md (ref=feedback_tmp_path_windows,
|
||||
# 6+ hits; also breaks run_in_background shells where TMPDIR is unset).
|
||||
#
|
||||
# Windows-only: exits 0 immediately on macOS/Linux where /tmp is one real dir.
|
||||
# Only WRITE patterns are blocked (redirects, tee, -o/--output, cp/mv/mktemp
|
||||
# targets). Reads (ls/cat/rm /tmp/...) pass.
|
||||
#
|
||||
# Dual-driver like block-backslash-winpath.sh: handles Claude (tool_input) and
|
||||
# Grok (toolInput) event shapes; emits Grok decision JSON when denying there.
|
||||
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN*) ;; # Windows Git-bash: the mismatch exists — check
|
||||
*) exit 0 ;; # real /tmp elsewhere: nothing to protect
|
||||
esac
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(echo "$input" | jq -r '(.toolInput // .tool_input // {}) | .command // ""' 2>/dev/null || python -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
ti = d.get('toolInput') or d.get('tool_input') or {}
|
||||
print(ti.get('command', ''))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null || echo '')
|
||||
is_grok=$(echo "$input" | jq -r 'if has("hookEventName") or has("toolInput") then "1" else "0" end' 2>/dev/null || echo '0')
|
||||
|
||||
# Strip quoted substrings so a /tmp mention inside a string (commit message,
|
||||
# grep pattern) does not false-trigger; real write targets sit outside quotes.
|
||||
bare=$(printf '%s' "$cmd" | sed -E "s/'[^']*'//g; s/\"[^\"]*\"//g")
|
||||
|
||||
if printf '%s' "$bare" | grep -qE '(>>?[[:space:]]*/tmp/|[[:space:]]tee[[:space:]]+(-a[[:space:]]+)?/tmp/|(^|[[:space:]])-o[[:space:]]*/tmp/|--output(=|[[:space:]]+)/tmp/|(^|[[:space:]]|;|\|)(cp|mv)[[:space:]][^|;&>]*[[:space:]]/tmp/|mktemp[[:space:]]+(-d[[:space:]]+)?/tmp/)'; then
|
||||
reason="Blocked write to /tmp in bash on Windows: MSYS /tmp and the Write/Python tools' /tmp are DIFFERENT directories — the file cannot be read back by the next tool call."
|
||||
echo "BLOCKED: do not write files under /tmp on Windows (Git Bash)."
|
||||
echo ""
|
||||
echo "MSYS bash and the harness's other tools resolve /tmp to different real"
|
||||
echo "directories, so the next tool call cannot read what you just wrote"
|
||||
echo "(ref: feedback_tmp_path_windows). Use one of these instead:"
|
||||
echo " - repo-relative scratch: curl -o ./.x.json ... (gitignored .tmp-* also works)"
|
||||
echo " - the session scratchpad dir (absolute path, shared by all tools)"
|
||||
echo " - pipe directly: curl ... | jq ... (no intermediate file)"
|
||||
if [ "$is_grok" = "1" ]; then
|
||||
printf '{"decision":"deny","reason":"%s"}\n' "$reason"
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,170 +1,220 @@
|
||||
# Memory Index
|
||||
|
||||
## Reference
|
||||
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
||||
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
|
||||
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — GURU-5070 now has cargo + MSVC + protoc; build/clippy/test guru-connect LOCALLY (set PROTOC to the winget path) instead of the build host. CI only clippy-checks the Linux server, not the Windows agent.
|
||||
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
||||
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
|
||||
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
||||
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
|
||||
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
||||
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
||||
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
||||
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
||||
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
||||
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
||||
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
||||
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
||||
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
||||
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
||||
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
|
||||
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
|
||||
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
||||
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
|
||||
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
|
||||
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
|
||||
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
|
||||
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
|
||||
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
|
||||
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
|
||||
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
|
||||
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
|
||||
|
||||
## Users
|
||||
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
||||
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
||||
|
||||
## Feedback
|
||||
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
|
||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
|
||||
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
|
||||
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
|
||||
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
|
||||
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
|
||||
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
||||
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
|
||||
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
|
||||
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
||||
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
||||
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
||||
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
||||
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
||||
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
||||
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
||||
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
||||
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
||||
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. Use single-quoted heredoc `<<'JSON'` + `--data-binary @-` for bodies; build `"` from `[char]34`; or drop the quoted part (e.g. `shutdown /c`).
|
||||
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
|
||||
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
|
||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
||||
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
|
||||
- [Antigravity agy.exe is not a headless CLI](reference_antigravity_agy_not_headless.md) — the `agy` skill's real backend is `@google/gemini-cli`, not the Antigravity `agy.exe` (IDE agent, no stdout, hangs). Don't reinstall agy.exe expecting headless output. Mike has a paid Gemini account, so stay on gemini-cli past the June 18 free-tier sunset (prefer `GEMINI_API_KEY`).
|
||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
|
||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
||||
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
||||
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
|
||||
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
||||
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
||||
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
||||
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
||||
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
|
||||
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
|
||||
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
|
||||
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
||||
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
||||
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
|
||||
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
||||
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
||||
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
||||
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
||||
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
||||
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
|
||||
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
|
||||
|
||||
### Syncro
|
||||
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
||||
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
||||
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
||||
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
||||
|
||||
### GuruRMM
|
||||
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
|
||||
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
|
||||
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
||||
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
||||
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
||||
|
||||
### Cascades
|
||||
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
|
||||
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
||||
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
|
||||
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
|
||||
|
||||
## Machine
|
||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
||||
|
||||
## Project
|
||||
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
|
||||
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
||||
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
||||
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
||||
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
||||
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
||||
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
||||
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
||||
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
|
||||
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
||||
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
||||
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
|
||||
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
|
||||
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
||||
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
||||
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
||||
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
||||
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
||||
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
||||
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
||||
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
||||
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
||||
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
||||
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
||||
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
||||
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; only method is GetSessionsByName; matches blank-for-agents Name field so it cannot enumerate full inventory.
|
||||
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
|
||||
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
|
||||
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
|
||||
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
|
||||
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
|
||||
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
|
||||
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
|
||||
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
|
||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
|
||||
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
|
||||
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
|
||||
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
|
||||
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
|
||||
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
|
||||
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
|
||||
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
|
||||
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
|
||||
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
|
||||
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
|
||||
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
|
||||
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
|
||||
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
|
||||
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
|
||||
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
|
||||
# Memory Index
|
||||
|
||||
## Reference
|
||||
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
||||
- [MSP360 backup monitoring](reference_msp360_backup_monitoring.md) — Authoritative "is client X actually backing up / last run" source for the whole fleet (NOT B2 bucket contents). MSP360 MBS API `/api/Monitoring`, vault `msp-tools/msp360-api.sops.yaml`. CompanyName also IDs B2 buckets (ACG-PST=Peaceful Spirit, ACG-TCA=Tucson Coin).
|
||||
- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill.
|
||||
- [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action.
|
||||
- [RMM map network drive (err67 double-hop)](reference_rmm_map_network_drive.md) — Pushing a persistent mapped drive to a remote share via /rmm user_session fails with err67/1702 (impersonated token = no network cred/double-hop). Plant HKCU:\Network\<drv> keys + cmdkey; mounts at next interactive logon. Immediate visibility needs the live session (ScreenConnect).
|
||||
- [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write.
|
||||
- [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder.
|
||||
- [Investigator EXO ManageAsApp gap](reference_investigator_exo_manageasapp_gap.md) — Security Investigator app lacks `Exchange.ManageAsApp` (only `full_access_as_app`) so `investigator-exo` 401s on EXO adminapi; use `exchange-op` tier for InvokeCommand.
|
||||
- [Windows edition upgrade via RMM](reference_windows_edition_upgrade_rmm.md) — Verified Home->Pro/Pro-for-Workstations fleet procedure: `changepk.exe /ProductKey <generic Pro>` (NOT DISM Set-Edition — Error 50) -> reboot with `Restart-Computer -Force` (`shutdown /r /t N` is silently dropped in SYSTEM session) -> `slmgr /ipk <MAK>` + /ato. The vault MAK is a Pro-for-WORKSTATIONS key (auto-upgrades edition past plain Pro).
|
||||
- [Tailscale subnet-route key expiry](reference_tailscale_subnet_key_expiry.md) — "internet OK but all of 172.16.3.x (Gitea .20, RMM/coord .30) dead" = Tailscale infra-node KEY EXPIRY (pfSense subnet router advertises 172.16.0.0/22), NOT a LAN outage; expiry now disabled on infra nodes (2026-06-25). Fallback: gururmm-server direct at tailnet 100.86.12.15:3001.
|
||||
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
|
||||
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — toolchain installed BUT a WDAC/Smart App Control policy (since 2026-07-04) BLOCKS running rustc/cargo/sops/openssl locally (os error 4551) — build the Windows agent on Beast, not here. Toolchain paths + CI gates still valid for reference.
|
||||
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
||||
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||
- [Syncro RMM policies = API-impossible](reference_syncro_rmm_api_gui_only.md) — policy create/assign/folder-move is GUI-ONLY; `policy_folder_id` is read-only on PUT (live-proven), policy endpoints 404, /policy_folders 401 scope-gated. Don't build /syncro move-asset; use `bitdefender` for API policy work.
|
||||
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
|
||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
|
||||
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
||||
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
|
||||
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
||||
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
||||
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
||||
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
||||
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
||||
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
||||
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
||||
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
||||
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
||||
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
||||
- [GuruRMM command timeout_seconds](reference_gururmm_command_timeout_seconds.md) — agent command dispatch honors `timeout_seconds`, NOT `timeout`; long jobs die ~300s / go zombie (`running`, empty stdout) otherwise. Cost Birth Biologic a full day.
|
||||
- [SharePoint Graph large-file upload](reference_sharepoint_graph_large_file_upload.md) — <4MB simple PUT, >=4MB MUST use chunked upload session (Content-Range); `\\?\` long paths; idempotent size-check; verify counts via /root/delta; single stream ~40Mbps (SPO throttle).
|
||||
- [RMM-spawn headless Claude](reference_rmm_spawn_headless_claude.md) — run `claude -p` on any RMM-managed Windows box with Claude Code (reaches coord-isolated sites like AD2); use `context:user_session`, UNSET the stale machine `ANTHROPIC_API_KEY` (shadows OAuth → "Invalid API key"), detach + poll a DONE marker. Validated on AD2 2026-07-01.
|
||||
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
|
||||
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
|
||||
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
||||
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
|
||||
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
|
||||
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
|
||||
- [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS.
|
||||
- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data.
|
||||
- [Syncro priority/type format](feedback_syncro_priority_type_format.md) — every ticket create needs a number-prefixed priority ("2 Normal", not bare "Normal" which renders blank) AND a valid problem_type. Winter flagged #32193/#32194. Use the syncro skill's create flow.
|
||||
- [Syncro line-item category](feedback_syncro_line_item_category.md) — product_category is a controlled vocab: never invent one, never invoice a line whose product has product_category:null (blank CATEGORY breaks QB mapping). Pre-flight GET /products/<id>. Winter fixed Cascades 67887/67890 "Windows Pro Upgrade".
|
||||
- [RMM drive-map Explorer refresh](reference_rmm_drive_map_explorer_refresh.md) — drive mapped via RMM user_session works but the user's running Explorer won't show it until SHChangeNotify(DRIVEADD); also UNC \\ gets eaten in heredoc+jq, build it from [char]92.
|
||||
- [Verify live before acting](feedback_verify_live_before_acting.md) — pull LIVE data (OMSA/iDRAC/live API) before acting on a hardware/infra flag; wiki/logs go stale. Cascades CS-SERVER "degraded RAID" was 9-day-stale (mirror self-recovered, SSDs bought needlessly). Windows can't see RAID member health.
|
||||
- [Windows Pro upgrade billing](feedback_windows_pro_upgrade_billing.md) — ACG MAK key (vault infrastructure/windows-pro-mak, Mike's ACG key) for Home->Pro upgrades; RULE: invoice $99 PER machine activated, name the machine on the line item, bill after success. Each use consumes a MAK count.
|
||||
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
|
||||
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
|
||||
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
|
||||
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
|
||||
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
|
||||
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
|
||||
- [reference_syncro_agent_handle_leak](reference_syncro_agent_handle_leak.md) -- RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
|
||||
|
||||
## Users
|
||||
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
||||
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
||||
|
||||
## Feedback
|
||||
- [Report times in Arizona time](feedback_timezone_arizona_reporting.md) — All user-facing times in America/Phoenix (MST, no DST), labeled AZ; convert from UTC. Set by Winter 2026-07-02.
|
||||
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
|
||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
|
||||
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
|
||||
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
|
||||
- [RMM Set-Acl timeout loses stdout](feedback_rmm_setacl_timeout_password_loss.md) — NTFS ACL propagation (Set-Acl/icacls) on a large folder tree exceeds the RMM command timeout and stdout is dropped, so a password printed in that script is lost. Generate secrets LOCALLY (placeholder-inject) so they survive; isolate the ACL grant into its own long-timeout command.
|
||||
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
|
||||
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
|
||||
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
||||
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
|
||||
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
|
||||
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
||||
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
||||
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
||||
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
||||
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
||||
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
||||
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
||||
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
||||
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
||||
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`.
|
||||
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
|
||||
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
|
||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
||||
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
|
||||
- [agy.exe NOW works headless (2026-07-03)](reference_antigravity_agy_not_headless.md) — Antigravity `agy.exe` v1.0.6+ has a working `-p/--print` headless mode (clean stdout, `--add-dir` to read files); use it as the Gemini/Antigravity second-model path, esp. when gemini-cli OAuth is ineligible. Call by full path until PATH refresh. (Supersedes the old "agy is DOA headless" note.)
|
||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
|
||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
||||
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
||||
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
|
||||
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
||||
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
||||
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
||||
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
||||
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
|
||||
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
|
||||
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
|
||||
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
||||
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
||||
- [Impeccable on outbound](feedback_impeccable_on_outbound.md) — Run the `impeccable` skill on anything sent to a client or vendor before delivery; internal drafts exempt.
|
||||
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
|
||||
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
||||
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
||||
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
||||
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
||||
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
||||
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
|
||||
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
|
||||
|
||||
### Syncro
|
||||
- [Skill-first routing + Definition of Done](feedback_skill_first_routing.md) — If a skill covers the task, INVOKE IT (Syncro billing ALWAYS via `/syncro`, never ad-hoc curl), AND gate the result with the matching check-skill before "done" (code→/code-review+/simplify+/security-review; outbound→impeccable/stop-slop; wiki/memory→wiki-lint/memory-dream) — inferred from the request, not user-named. CORE rule; full map in `.claude/SKILL_ROUTING.md`; calibrate to stakes, Ollama Tier-0 for cheap passes.
|
||||
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
||||
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
||||
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
||||
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
||||
|
||||
### GuruRMM
|
||||
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
|
||||
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
|
||||
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
||||
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
||||
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
||||
|
||||
### Cascades
|
||||
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
|
||||
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
||||
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
|
||||
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
|
||||
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
|
||||
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
|
||||
|
||||
## Machine
|
||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
||||
|
||||
## Project
|
||||
- [GuruRMM software-removal PR #58](project_gururmm_software_removal_pr58.md) — SPEC-030 hardening (driver exclusion in removal UI, lingering-uninstaller sweep, job list + stale reaper) on PR #58, UNMERGED; merge = beta deploy; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
|
||||
- [Cascades network + CS-SERVER SMB instability](project_cascades_network_segments.md) — NAS->CS-SERVER migration. NOT a CSC-ENT-vs-CSCNet issue (corrected): fresh SMB to `\\cs-server\*` fails err 67 from BOTH Wi-Fis; only persistent admin-mapped drives work, intermittently → CS-SERVER-side SMB instability (multichannel advertises .248/.254/IPv6 ULAs; Get-SmbServerConfiguration errors). Blockers: CSCNet=WPA3 (old adapters can't join); workstations store domain-admin cred. Repoint tool [[drive-map]].
|
||||
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
|
||||
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
||||
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
||||
- [GuruRMM security scope — integrate AV, don't replace it](project_gururmm_security_scope.md) — No native virus/malware removal in the RMM; AV products do that. RMM monitors AV reports + sends commands to AV products, and its built-in value is helping techs FIND issues. Program removal is a separate feature.
|
||||
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
||||
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
||||
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
||||
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
||||
- [GuruRMM legacy 1.77 build disabled](project_gururmm_legacy_disabled.md) — legacy (2008R2/Win7) Rust 1.77 variant DISABLED 2026-07-04 (`LEGACY_ENABLED=false`) after an edition2024 dep (chacha20/rand_core 0.10.1) broke the unpinned 1.77 re-resolve and fail-closed the whole Windows build; existing legacy artifacts preserved at 0.6.76. Do NOT re-enable blindly — 2008R2 support returns via a C++/.NET or clean-rewrite legacy agent (Mike, required).
|
||||
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
||||
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
|
||||
- [Cascades CARF tech plan](project_cascades_carf_tech_plan.md) — Ashley's "technology plan" is a CARF accreditation deliverable (Aging Services Technology and System Plan standard); must use CARF action-plan structure (owner/cost/target+completion dates per area), persons-served assistive-tech lens, annual-review sign-off; it's Cascades' leadership-adopted plan, ACG supplies content.
|
||||
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
||||
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
||||
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
|
||||
- [Cascades VLAN20 migration + routing](project_cascades_vlan20_migration_routing.md) — Staff machines/printers moving to VLAN20 (10.0.20.0/24). CS-SERVER couldn't reach VLAN20 printers because the LAN "allow LAN to any" rule policy-routes via WAN_Group → add a top LAN pass rule (src CS-SERVER 192.168.2.248, dst 10.0.20.0/24, gw=default) to bypass. pfSense SSH from VPN is blocked (do firewall in GUI). Printer client-map via GPO or SYSTEM `printui /ga` to dodge the 0x800702e4 PrintNightmare prompt; build UNC with [char]92.
|
||||
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
|
||||
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
||||
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
||||
- [AMPIPIT toolkit](project_ampipit.md) — Howard's ACTIVE Rust+Slint Windows deploy/recovery toolkit (not just a reference program), migrated into the fleet as a private Gitea submodule at projects/msp-tools/ampipit (azcomputerguru/ampipit). Active feature: recovery partition with GuruRMM agent + built-in repair scripts for offline-Windows repair (spike GO-WITH-WORK). Preserve all work; PRIVATE Gitea only, no public GitHub.
|
||||
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
||||
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
||||
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
||||
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
||||
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
||||
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
||||
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
||||
- [Defender: exempt all Claude activity](feedback_defender_claude_exclusions.md) — Mike wants NOTHING Claude/ClaudeTools issues flagged by Defender; ClickFix threat IDs 2147939088/2147945138 (RMM curl dispatch AMSI FPs) set to Allow + broad process/path exclusions. ThreatID-Allow is the lever; exclusions alone don't stop AMSI CmdLine detections.
|
||||
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
||||
- [VoIP vendor stack: PacketDial / OIT / NetSapiens / YMCS](reference_packetdial_oit_netsapiens.md) — PacketDial = ACG's VoIP-dept brand (pbx.packetdial.com, the `packetdial` skill); NetSapiens = the PBX platform (API v2); OIT/OITVOIP = white-label wholesaler running NetSapiens (api.ucaasnetwork.com); reseller `91912.service`. YMCS (Yealink) = phone device-mgmt, pairs with the PBX.
|
||||
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
||||
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
||||
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; now wrapped by the /screenconnect skill. Verified surface: GetSessionsByName/GetSessionDetails + writes SendCommand/SendMessage/UpdateCustomProperties + parameterized self-tagging installer. Still NO full-fleet inventory method (GetSessions missing).
|
||||
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
|
||||
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
|
||||
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
|
||||
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
|
||||
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
|
||||
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
|
||||
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
|
||||
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
|
||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
|
||||
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
|
||||
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
|
||||
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
|
||||
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
|
||||
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
|
||||
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
|
||||
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
|
||||
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
|
||||
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
|
||||
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
|
||||
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
|
||||
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
|
||||
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
|
||||
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
|
||||
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
|
||||
- [GuruScan verification IN TEST / paused](project_guruscan_in_test_paused.md) — multi-engine scanner verify on DESKTOP-MS42HNC paused 2026-06-22 (VM rebooted mid-Emsisoft run); HitmanPro done (36 removed), Emsisoft full-scan unverified; resume `guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft`; Defender RTP/Tamper still off on VM
|
||||
- [GuruRMM fleet dispatch-hang fix](project_gururmm_dispatch_hang_fix.md) — blocking send_to on a full bounded channel to one black-holed agent wedged ALL command dispatch; fixed with try_send (9dae20c, deployed); proper black-hole eviction still missing (was reverted in 80df458) — finish it if it recurs
|
||||
- [Windows won't-boot / offline DISM repair playbook](windows-offline-dism-repair-gotchas.md) — Automatic Repair loop = boot-critical fault (disk/registry/wedged update), NOT shell/appx store corruption (that's a symptom); `FaultyPackageInProgress` + 100s of Install/Uninstall-Pending packages = wedged CU -> RevertPendingActions or clean install. Offline DISM rejects `wim:` source (0x800f082e) -> MOUNT the wim, source `\Windows`. Ventoy breaks WIM mount (0xc1420134) -> use Rufus. 25H2(26200)=24H2(26100)+enablement, so match 26100 media. First hit: Four Paws AvImark #32447.
|
||||
- [365 app suite — authoritative map + consent-drift fix](reference_365_app_suite.md) — full map in `.claude/skills/remediation-tool/references/app-suite.md`; per-tenant consent is NOT uniform (VWP had the app but no SharePoint role). Run `consent-audit.sh <tenant|--all>` to detect gaps; fix via adminconsent URL or direct appRoleAssignment grant.
|
||||
- [Remediation-tool has full M365 access (incl. SharePoint)](reference_remediation_tool_365_access.md) — the app suite covers Graph/EXO/Defender/SharePoint; don't declare "no access" on an accessDenied. SharePoint app-only needs a CERT (secret = "Unsupported app only token"); use get-token.sh `sharepoint`/`sharepoint-admin` tiers + CSOM admin API (Graph /admin/sharepoint/settings scope not held). Full map: skill references/app-permissions-and-sharepoint.md.
|
||||
- [AV migration: Bitdefender -> Datto EDR](project_av_migration_bitdefender_to_edr.md) — retire Bitdefender fleet-wide, ONLY exception Glaztech (Dataforth migrates); end-state per machine = GuruRMM + Datto EDR
|
||||
- [RMM deploy via ScreenConnect](reference_rmm_deploy_via_screenconnect.md) — push GuruRMM agent to client workstations via SC send-command (SYSTEM), not DC remote-exec (DCOM/schtasks blocked on Win11 clients)
|
||||
- [ScreenConnect custom-property slots](reference_screenconnect_custom_property_slots.md) — CP1=Company CP2=Site CP3=Department CP4=Device Type CP8=Tag (API hides labels; UpdateSessionCustomProperties replaces the whole array)
|
||||
- [ScreenConnect cleanup uses wiki as source](feedback_screenconnect_cleanup_wiki_source.md) — per-client SC/RMM metadata cleanup pulls machine->dept/location from the client wiki; enrich the wiki when missing
|
||||
- [TECH03L systemprofile shortcut corruption](project_tech03l_systemprofile_shortcut_corruption.md) — Auto-Claude "opens then closes" = .lnk pointing at nonexistent systemprofile path; repoint, do not debug the app
|
||||
- [Claude tui fullscreen crash](feedback_claude_tui_fullscreen_crash.md) — "flicker-free" prompt writes tui=fullscreen; instant-exit crash loop on some Windows terminals; set tui=default (reinstall does NOT fix)
|
||||
- [Backup targets never guessed](feedback_backup_targets_never_guessed.md) — billed-vs-running mismatches are billing questions for Mike/Winter; never infer/deploy backup targets
|
||||
- [Fire #dev-alerts for client/RMM work](feedback_fire_dev_alerts_for_client_work.md) — Howard+Mike watch #dev-alerts Discord for live visibility into what techs/sessions touch. Fire post-bot-alert.sh ([DEV]/[RMM]/[DEPLOY]/[GURURMM] prefix -> #dev-alerts) at the START of any RMM/Dev or active-client-machine action. No CLAUDE.md rule yet — this memory is the rule.
|
||||
|
||||
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal file
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: feedback_backup_targets_never_guessed
|
||||
description: Backup targets are a deliberate per-client decision - never infer which machine needs backup or deploy backup jobs from billing counts
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When reconciling billed backup lines (e.g. Syncro "Service - Data Backup" qty) against
|
||||
machines actually backing up (MSP360/B2), a mismatch is a BILLING question for Mike/Winter,
|
||||
never a deployment instruction. (Howard, 2026-07-05, GPS->RMM audit backup verification.)
|
||||
|
||||
**Why:** which machine gets backed up is a deliberate choice made per client (usually the
|
||||
server or a specific data machine) — billed qty does not mean "N machines each need a
|
||||
backup job." Guessing targets and adding backup jobs would burn storage and misrepresent
|
||||
what the client agreed to.
|
||||
|
||||
**How to apply:** report mismatches (billed > running, running > billed, dead buckets) as
|
||||
findings with evidence; route the decision to Mike/Winter/client. Only configure a backup
|
||||
job when the target machine is explicitly named by the user/client. Related:
|
||||
[[project_av_migration_bitdefender_to_edr]] (the AV migration IS deploy-everywhere, unlike
|
||||
backup — don't conflate the two postures).
|
||||
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal file
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: claude-tui-fullscreen-crash
|
||||
description: Claude Code "flicker-free rendering" prompt writes tui=fullscreen to settings.json and can crash-loop the CLI on Windows; fix = delete the tui key
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
On ACG-TECH03L (2026-07-04, Claude Code 2.1.201), accepting the startup prompt asking to
|
||||
enable **flicker-free rendering** wrote `"tui": "fullscreen"` into
|
||||
`~/.claude/settings.json`. On that machine's terminal the fullscreen/alternate-screen
|
||||
mode crashed the CLI instantly on every subsequent launch ("opens and then closes") —
|
||||
from any launch path, since all read the same settings file.
|
||||
|
||||
**Why:** the setting persists, so one bad answer to the prompt turns into a permanent
|
||||
crash loop; reinstalling Claude Code does NOT fix it because settings survive reinstall.
|
||||
|
||||
**How to apply:** if a fleet machine's Claude Code exits immediately at launch, check
|
||||
`~/.claude/settings.json` for `"tui": "fullscreen"` and set it to `"tui": "default"`
|
||||
(surgically — keep hooks/plugins; explicit "default" also suppresses re-prompting,
|
||||
which deleting the key does not). Emergency override without touching config:
|
||||
`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`. Decline the flicker-free prompt on machines
|
||||
with older terminals/conhost. Related: [[tech03l-systemprofile-shortcut-corruption]].
|
||||
18
.claude/memory/feedback_defender_claude_exclusions.md
Normal file
18
.claude/memory/feedback_defender_claude_exclusions.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: feedback_defender_claude_exclusions
|
||||
description: Mike wants NOTHING Claude/ClaudeTools issues to be flagged by Windows Defender; keep broad exclusions + allow the ClickFix threat IDs that fire on RMM curl dispatch.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
On his workstation (GURU-*), Mike wants **nothing Claude issues to be affected by Defender AV** — it's a constant irritation. The recurring hits are `Trojan:Win32/ClickFix.DBD!MTB` (ThreatID 2147939088) and `Trojan:Win32/ClickFix.ZF` (ThreatID 2147945138), fired by Defender's AMSI **command-line** scan on the base64-PowerShell payloads that `curl.exe` POSTs to the GuruRMM coordination API (`172.16.3.30:3001/api/agents/.../command`).
|
||||
|
||||
**Why:** These are false positives on legitimate ClaudeTools/GuruRMM command dispatch. He's the admin/owner and made an informed call to allow the family.
|
||||
|
||||
**How to apply:** Process/path exclusions alone do NOT stop these — AMSI CmdLine/behavioral detections ignore `ExclusionProcess`/`ExclusionPath`. The lever that works is `Add-MpPreference -ThreatIDDefaultAction_Ids <id> -ThreatIDDefaultAction_Actions Allow` (Allow = action 6) for both IDs. Also maintained (elevated PowerShell):
|
||||
- ExclusionProcess: bash.exe, curl.exe, git.exe, node.exe, claude.exe
|
||||
- ExclusionPath: `C:\Program Files\Git` (+ mingw64\bin, usr\bin), `C:\Program Files\nodejs`, `C:\Users\<u>\.claude`, `C:\Users\<u>\.local\bin`, `C:\Users\<u>\AppData\Roaming\npm`, `C:\ClaudeTools`, `D:\ClaudeTools`.
|
||||
|
||||
**ACTIVE (2026-07-01):** Mike opted for the fully-blanket lever — `Set-MpPreference -DisableScriptScanning $true` is SET on this box, disabling Defender AMSI script scanning machine-wide (his call: "I'm not likely to fall for bogus scripts"). This alone stops the CmdLine detections regardless of variant ID; the ThreatID-Allows + exclusions remain as belt-and-suspenders. If ever re-enabling, `Set-MpPreference -DisableScriptScanning $false`.
|
||||
|
||||
**Fleet application (2026-07-01):** `DisableScriptScanning` is **Tamper-Protection-gated** — it silently stays `False` if TP is on, even from SYSTEM. This workstation's TP is OFF (toggle worked); **GURU-BEAST-ROG's TP is ON**, so on Beast only the exclusions + ClickFix ThreatID-Allows applied via RMM (those aren't tamper-gated and DO cover the recurring detections) — the blanket script-scanning kill there needs a manual Windows Security UI toggle (TP can't be disabled by script). Beast (GURU-BEAST-ROG, AZ Computer Guru/Mike's House, RMM id 5233d75b-...) is "treated like this machine." Howard was OFFERED the same via Discord DM — his choice on his own box; do NOT push to Howard's machine without his ok. Related: [[reference_acg_msp_stack]] (ACG's own tools shouldn't be flagged as threats), [[feedback_windows_quote_stripping]].
|
||||
12
.claude/memory/feedback_exchange_op_all_access.md
Normal file
12
.claude/memory/feedback_exchange_op_all_access.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback_exchange_op_all_access
|
||||
description: The exchange-op tier is the all-access Exchange tier — stop claiming "no tier can write mail"
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
The **`exchange-op`** tier (ComputerGuru **Exchange Operator** app, `b43e7342-5b4b-492f-890f-bb5a4f7f40e9`) holds the **Exchange Administrator** directory role PLUS `full_access_as_app` and `Exchange.ManageAsApp`. That is **full all-access to every mailbox and every Exchange Online operation** — reading, writing, moving mail, inbox rules, message trace, TABL, audit config, EWS, the lot.
|
||||
|
||||
**Why:** Mike's recurring correction (2026-06-25) — I keep claiming "no app tier has Mail.ReadWrite, so I need a workaround" and reaching for convoluted paths (EWS gymnastics, etc.). That framing is wrong and wastes time EVERY time. Graph application Mail.ReadWrite is not the only write path; the Exchange Operator app already has full Exchange admin rights.
|
||||
|
||||
**How to apply:** For ANY mailbox/Exchange write or all-access need (move/copy/delete mail, modify rules, change mailbox config, EWS operations, audit settings), default to the **`exchange-op`** tier. Never declare a task blocked for lack of mail-write permission without first using exchange-op. The Graph `investigator` tier is read-only (`Mail.Read`); `investigator-exo` lacks `Exchange.ManageAsApp` (see [[reference_investigator_exo_manageasapp_gap]]) — neither limitation means "we can't write," it just means use exchange-op. See [[reference_tedards_tenant_facts]].
|
||||
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal file
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: feedback_fire_dev_alerts_for_client_work
|
||||
description: Fire a #dev-alerts Discord post (post-bot-alert.sh with an [RMM]/[DEV]/[DEPLOY]/[BUILD]/[GURURMM] prefix) at the START of any RMM/Dev or active-client-machine action — Howard+Mike watch that channel for live visibility into what techs/sessions are touching
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Howard and Mike watch the **#dev-alerts** Discord channel to see, in real time, what the other techs
|
||||
(and Claude sessions) are working on. Any substantive action on **RMM/Dev** or an **ACTIVE CLIENT
|
||||
machine** MUST post a #dev-alerts heads-up — ideally at the START of the work, not just after.
|
||||
|
||||
**What counts:** SSH into a client server, restarting/reconfiguring services, editing boot/config on a
|
||||
live box, RMM remote commands, agent/server/dashboard deploys, channel promotions, anything with real
|
||||
blast radius on production/client infra.
|
||||
|
||||
**Why:** without it a session can be hand-editing a live client box (e.g. Lonestar Electrical's Unraid
|
||||
`/boot/config/go`, cycling libvirt) with ZERO visibility to the team — exactly what happened
|
||||
2026-07-06, which Mike flagged: "all of the actions you've taken today should have fired the discord
|
||||
alerts."
|
||||
|
||||
**How to apply:**
|
||||
`bash .claude/scripts/post-bot-alert.sh "[DEV] <client/box> — <what + why>. — Claude @ <machine> (<user>)"`
|
||||
Prefixes `[RMM] [DEPLOY] [DEV] [BUILD] [GURURMM] [SMARTBADGE-WATCH]` auto-route to the PRIVATE
|
||||
#dev-alerts (Howard+Mike only, channel 1509998508198068484); everything else → #bot-alerts (whole
|
||||
team). It soft-fails (best-effort), so it never blocks the work. Channel IDs + routing live in
|
||||
`.claude/scripts/post-bot-alert.sh`.
|
||||
|
||||
As of 2026-07-06 there is NO CLAUDE.md rule enforcing this — treat this memory AS the rule until a core
|
||||
rule/hook lands (worth proposing one). Related: [[discord-dm]] skill for direct DMs.
|
||||
20
.claude/memory/feedback_impeccable_on_outbound.md
Normal file
20
.claude/memory/feedback_impeccable_on_outbound.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: feedback_impeccable_on_outbound
|
||||
description: Run the `impeccable` skill on any deliverable before it goes out to a client or vendor
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Before sending ANYTHING to a client or another vendor — proposals, plans, reports,
|
||||
agendas, one-pagers, emails meant to represent ACG externally — run the **`impeccable`**
|
||||
skill on it first as a quality/polish gate. Applies to outbound, external-facing
|
||||
deliverables; internal prep docs and working notes do not require it.
|
||||
|
||||
**Why:** Mike wants everything that leaves ACG to be polished and on-brand. A rough
|
||||
internal draft is fine for us; a client/vendor never sees an unpolished artifact.
|
||||
|
||||
**How to apply:** When a deliverable is destined for a client/vendor, produce it, then
|
||||
invoke `impeccable` to audit/polish before delivery. For document deliverables, that
|
||||
means rendering them as a designed artifact (styled HTML/PDF one-pager) so `impeccable`
|
||||
(a frontend/UI design skill) can do its job — confirm the format with the user if unsure.
|
||||
Pairs with [[feedback_client_tone]] (tone) and [[stop-slop]] (text quality).
|
||||
29
.claude/memory/feedback_rmm_setacl_timeout_password_loss.md
Normal file
29
.claude/memory/feedback_rmm_setacl_timeout_password_loss.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: feedback_rmm_setacl_timeout_password_loss
|
||||
description: RMM Set-Acl/icacls ACL propagation on large folder trees exceeds the command timeout; stdout is dropped on timeout so any value printed in that script (e.g. a generated password) is lost.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When dispatching `/rmm` commands that change NTFS ACLs (`Set-Acl`, `icacls /grant`) on a
|
||||
**large folder tree**, ACL inheritance propagation to existing children can take minutes and
|
||||
**exceed `timeout_seconds`** — the agent reaper marks the command `failed` with
|
||||
`"Execution error: Command timeout"`, and **stdout is discarded**. Proven 2026-06-25 setting up
|
||||
Nick's SMB share on REDNOURCARRIEVI (Carrie's `Documents` tree): the same script generated a
|
||||
random password and printed it, then ran `Set-Acl` and timed out — the password was gone twice.
|
||||
|
||||
**Why:** PowerShell `Set-Acl` (and `icacls` even without `/T`) re-stamps inheritable ACEs onto
|
||||
all existing children; on a big tree that blows past 90–120s. `Set-LocalUser`/`New-LocalUser`
|
||||
themselves are instant — the cost is the ACL walk.
|
||||
|
||||
**How to apply:**
|
||||
- **Never depend on a value you can only read back from stdout in a command that might time out.**
|
||||
Generate passwords/secrets **locally** in the Bash tool (retain them), inject via a placeholder
|
||||
in a `<<'PS'` heredoc (`SCRIPT="${SCRIPT/__PW__/$PW}"`) so PowerShell `$env:` survives — then
|
||||
even a timeout doesn't lose the value.
|
||||
- **Isolate the slow ACL step** into its own command with a long `timeout_seconds` (>=600) and
|
||||
poll across multiple Bash calls (each Bash call is capped ~2 min).
|
||||
- For share access, share-level perms (`Grant-SmbShareAccess`) and the account creation are fast;
|
||||
only the NTFS grant is slow.
|
||||
|
||||
See errorlog (`rmm/acl`, --friction) and [[reference_gururmm]].
|
||||
23
.claude/memory/feedback_screenconnect_cleanup_wiki_source.md
Normal file
23
.claude/memory/feedback_screenconnect_cleanup_wiki_source.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: feedback_screenconnect_cleanup_wiki_source
|
||||
description: ScreenConnect/RMM machine-metadata cleanup uses the client wiki as source of truth; enrich the wiki when info is missing
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
For the ScreenConnect session-hygiene + RMM-site cleanup (per client: normalize Company,
|
||||
set Site/Department/Device Type/Tag, fix RMM sites, dedup) — **the client wiki is the primary
|
||||
source of truth for machine -> department / person / location.** (Howard, 2026-07-03.)
|
||||
|
||||
**Why:** the wiki already carries person->machine->department maps (e.g. Cascades: Ashley
|
||||
Jensen->Accounting on DESKTOP-U2DHAP0, Shelby Trozzi->MemCare Director on MDIRECTOR-PC), so
|
||||
department/site can be derived from it rather than guessed.
|
||||
|
||||
**How to apply, per client:**
|
||||
1. Pull the machine metadata from the client wiki FIRST (`wiki/clients/<slug>.md` + its source docs), then hostname role tokens, then UniFi switch/AP building/area names.
|
||||
2. Where a machine's department/location is NOT in the wiki, **learn it (ask the user / investigate) and UPDATE the wiki** with the finding — so the wiki becomes the durable record and the next pass is easier.
|
||||
3. Slot mapping = [[reference_screenconnect_custom_property_slots]] (CP1 Company, CP2 Site, CP3 Department, CP4 Device Type, CP8 Tag). Match each client's EXISTING vocabulary (e.g. Cascades uses Device Type "Desktop"/"Laptop"/"Server", not "Workstation"; Dataforth uses "Workstation").
|
||||
4. UniFi placement: Dataforth = cloud UDM via Site Manager connector; Cascades = UOS controller. AP/switch names are building/area-coded. Related: [[project_av_migration_bitdefender_to_edr]], GPS->RMM audit `projects/gps-rmm-audit/tracker.md`.
|
||||
|
||||
Done so far: Dataforth (D1/D2 sites split + tags), Cascades (single-site + departments). Remaining
|
||||
per-client unknowns get filled from the wiki as we walk each machine; wiki gets updated when it doesn't have it.
|
||||
20
.claude/memory/feedback_skill_first_routing.md
Normal file
20
.claude/memory/feedback_skill_first_routing.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: feedback_skill_first_routing
|
||||
description: If an installed skill/command covers a request, INVOKE THE SKILL — never hand-roll the API from memory. Syncro billing/invoicing ALWAYS goes through /syncro (or /syncro-emergency-billing). Knowing the API is not a license to bypass the skill.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When a request maps to an installed skill or slash-command, **invoke that skill** rather than improvising raw `curl`/API calls. This is a hard rule, now in CORE `CLAUDE.md` ("Skill-first"). The canonical offender is **Syncro billing**: every invoice/line-item/ticket-billing request goes through the `/syncro` skill (or `/syncro-emergency-billing` for after-hours) — NOT ad-hoc API calls.
|
||||
|
||||
**Two halves (CORE "Definition of Done"):** (1) route the REQUEST to a *doing-skill*, then (2) GATE the result with the matching *check-skill* BEFORE calling work done — automatically, inferred from the request + expected end result, WITHOUT the user naming skill A/B/C. Standing gates: code edits → `/code-review` + `/simplify` (+ `/security-review` if it touches auth/creds/security); outbound/client-facing → `impeccable`/`stop-slop`; Syncro/vendor → the skill's own preview+confirm gate; wiki/memory → `wiki-lint`/`memory-dream`. **Calibrate to stakes** (skip heavy review on a typo) and push cheap classify/prose passes to **Ollama Tier-0** to stay low-token. Full request→doing-skill→check-skill table: **`.claude/SKILL_ROUTING.md`** (read on demand). Decided with Mike 2026-06-26: enforcement tier "A+B" (CORE rule + on-demand map; deterministic Stop-hook backstop intentionally deferred until proven, to avoid friction).
|
||||
|
||||
**Why:** I default to "act directly" and, because I "know" the Syncro REST API, I reach for a hand-rolled `add_line_item` curl from memory. Free-handing the payload gets the structure wrong (attribution/`?api_key=` owner, `taxable:false`, line-item shape, priority/type format, blank contact, the preview gate), producing malformed tickets — and **Winter has to fix them** (already flagged on #32193/#32194 and others). The skill encodes all of that correctly and enforces the preview/confirm gate. The detailed billing rules in [[feedback_syncro_billing]] describe what the SKILL does when it bills; they are NOT a license to bypass the skill and do it by hand.
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
- 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]].
|
||||
@@ -5,11 +5,18 @@ metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
The superproject's background auto-sync resets each submodule's working tree to the **pinned
|
||||
gitlink** (which intentionally lags `main`), and 3-4 Claude sessions can share one submodule
|
||||
checkout. So inside `projects/msp-tools/guru-rmm` (and guru-connect) **local branch refs / HEAD do
|
||||
NOT reliably survive across tool calls or sessions** — a `git switch -c feat` can get reset to the
|
||||
gitlink mid-work, commits land on a detached HEAD, and `push -u origin <branch>` ships a stale ref.
|
||||
**UPDATE 2026-06-22:** `sync.sh` now protects submodule work on BOTH destructive paths. The
|
||||
2026-06-21 fix only guarded the Phase-1a init (`sync.sh:391`, init-only-if-unpopulated); the
|
||||
Phase-3 **post-rebase** reconcile (`sync.sh:525`) still ran `git submodule update --init
|
||||
--recursive` unconditionally and re-detached/reset everything — that's the path that ate a
|
||||
feature branch + commits mid-build today. New `submodule_update_safe()` advances ONLY submodules
|
||||
in the pristine pinned state (clean, detached HEAD) and SKIPS any on a branch or with uncommitted
|
||||
changes. **So: working on a real branch in a submodule now survives sync.** Prefer that.
|
||||
|
||||
Historically (and still as belt-and-suspenders), the auto-sync reset each submodule's working
|
||||
tree to the **pinned gitlink** (which intentionally lags `main`), and 3-4 Claude sessions can
|
||||
share one submodule checkout — so local branch refs / HEAD could get reset to the gitlink
|
||||
mid-work, commits land on a detached HEAD, and `push -u origin <branch>` ships a stale ref.
|
||||
|
||||
**Do this instead:**
|
||||
- **Feature work:** `git worktree add <path> origin/main`, edit + commit + push there, then
|
||||
@@ -27,12 +34,13 @@ gitlink mid-work, commits land on a detached HEAD, and `push -u origin <branch>`
|
||||
is HTTP, submodule is a different host; SSH key not authorized here).
|
||||
|
||||
**Why:** recurring across the fleet (Howard-Home detached-HEAD x2 + a stale-gitlink audit;
|
||||
GURU-5070 hit a non-fast-forward on a docs push this session). Each occurrence costs a
|
||||
re-diagnose/rebuild cycle. Howard fixed the `sync.sh` submodule-clobber root cause + moved to
|
||||
worktrees (2026-06-21), but the defensive discipline still applies.
|
||||
GURU-5070 hit a non-fast-forward on a docs push; 2026-06-22 the Phase-3 path reset guru-rmm
|
||||
mid-build). The 2026-06-21 fix was incomplete (Phase-1a only); the 2026-06-22 fix
|
||||
(`submodule_update_safe()`) closes the Phase-3 post-rebase path too.
|
||||
|
||||
**How to apply:** worktree or push-by-SHA + `ls-remote` verify for writes; assert HEAD==origin/main
|
||||
(or read `origin/main:<file>`) before audits; never `checkout --` shared files.
|
||||
**How to apply:** Work on a real branch in the submodule (now survives sync) and push it to origin.
|
||||
Belt-and-suspenders for high-stakes writes: push-by-SHA + `ls-remote` verify. Assert
|
||||
HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files.
|
||||
|
||||
Related: [[gururmm-session-logs-submodule-save]] [[feedback_gururmm_build_verification]]
|
||||
[[feedback_verify_committed_state_before_push]] [[using-git-worktrees]]
|
||||
|
||||
@@ -7,6 +7,8 @@ metadata:
|
||||
|
||||
Rules only. Incident detail, verbatim Mike quotes, ticket numbers, dates, the tech user_id table, and the labor-product table all live in [[feedback_syncro_history]] — read on-demand when judging an edge case. API mechanics: [[feedback_syncro_api]]. Workflow: [[feedback_syncro_workflow]].
|
||||
|
||||
**Run billing THROUGH the `/syncro` skill — do not hand-roll these calls.** The rules below describe what the skill does when it bills; they are the spec, not a license to free-hand `curl` from memory (that produces malformed tickets Winter has to fix). Invoke `/syncro` (after-hours -> `/syncro-emergency-billing`). See [[feedback_skill_first_routing]]. "`add_line_item` directly" (§1) means "not the timer workflow" — NOT "bypass the skill."
|
||||
|
||||
`.claude/commands/syncro.md` is the authoritative live product table.
|
||||
|
||||
---
|
||||
@@ -86,14 +88,34 @@ Always set `price_retail` explicitly — the rate doesn't auto-populate and the
|
||||
|
||||
---
|
||||
|
||||
## 8. Corrections preserve the ORIGINAL tech's attribution; ticket ownership is sticky
|
||||
## 8. API key follows the BILLING TECH — always
|
||||
|
||||
**Labor lines:** when fixing a wrong line, preserve the **original tech's** `user_id` so their commission isn't lost.
|
||||
- **Prefer `update_line_item` in place** — it preserves `user_id`.
|
||||
- **If you must remove + re-add:** the new line defaults to the **API-key owner's** `user_id`. Explicitly set `user_id` to the original tech on `add_line_item`, or PUT-fix it afterward.
|
||||
- Determine the original tech from `.ticket.user_id` and the line's `.user_id` BEFORE correcting; verify after.
|
||||
**Attribution is determined by which API key you use.** Every `add_line_item` / `remove_line_item` call is logged as the owner of that key. `user_id` in the payload does NOT override this.
|
||||
|
||||
**Ticket ownership:** adding notes or labor does **NOT** change `.ticket.user_id`. Multiple techs routinely work the same ticket. Only change ticket ownership when explicitly asked. Status PUTs send only `status`; line edits use `update_line_item`; neither touches `user_id`.
|
||||
**Common-sense defaults (confirmed by Howard 2026-06-23):**
|
||||
- Howard asks for billing → use Howard's key (he's billing himself)
|
||||
- Mike asks for billing → use Mike's key
|
||||
- Told "put X hours in for [tech]" → use that tech's key, regardless of who is asking
|
||||
- Split ticket ("2 hrs for Mike, 1 hr for Howard") → two separate `add_line_item` calls, each with the correct tech's key
|
||||
|
||||
**Vault paths:**
|
||||
- Howard → `msp-tools/syncro-howard.sops.yaml` → `credentials.credential`
|
||||
- Mike → `msp-tools/syncro.sops.yaml` → `credentials.credential`
|
||||
|
||||
```bash
|
||||
HOWARD_KEY=$(bash .claude/scripts/vault.sh get-field msp-tools/syncro-howard credentials.credential)
|
||||
MIKE_KEY=$(bash .claude/scripts/vault.sh get-field msp-tools/syncro credentials.credential)
|
||||
|
||||
# Each line item call uses the BILLING TECH's key as a query param:
|
||||
curl -s -X POST "https://computerguru.syncromsp.com/api/v1/tickets/{id}/add_line_item?api_key=${HOWARD_KEY}" ...
|
||||
curl -s -X POST "https://computerguru.syncromsp.com/api/v1/tickets/{id}/add_line_item?api_key=${MIKE_KEY}" ...
|
||||
```
|
||||
|
||||
**Auth note:** `?api_key=` is the attribution mechanism. The `Authorization: <key>` header works for reads but does NOT control line-item attribution — always use `?api_key=` for billing writes.
|
||||
|
||||
**Corrections:** wrong key used → `remove_line_item` with any key (doesn't matter), then re-`add_line_item` with the correct tech's key. `update_line_item` does NOT fix `user_id`.
|
||||
|
||||
**Ticket ownership:** adding notes or labor does NOT change `.ticket.user_id`. Multiple techs routinely work the same ticket. Only change ticket ownership when explicitly asked.
|
||||
|
||||
Tech user_id table → [[feedback_syncro_history]].
|
||||
|
||||
|
||||
15
.claude/memory/feedback_syncro_line_item_category.md
Normal file
15
.claude/memory/feedback_syncro_line_item_category.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: feedback_syncro_line_item_category
|
||||
description: Syncro product_category is a controlled vocabulary — never invent one, never invoice a blank-category line
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When billing in Syncro, every invoice/ticket line item carries a CATEGORY (`product_category`) that is a **controlled, admin-defined vocabulary** and drives QuickBooks item mapping + revenue reporting. Two hard rules:
|
||||
|
||||
1. **Never make up a category.** Use only values that already exist in the tenant (e.g. `Labor`, `Software`, `Hardware`, `Product`, `Service`/`Services`, `Subscriptions`, `Contract`, `Discount`, `Exempt Labor`, `Prepay Hours`, `Other`, `Deposit`, `Default`). Enumerate the live set from `GET /products` rather than hardcoding. If none fits, ASK — do not guess.
|
||||
2. **Never invoice a line whose product has `product_category: null`.** Pre-flight with `GET /products/<id>`; a blank category = an uncategorized line. Flag/fix the product (Windows/OS/software → `Software`, physical → `Hardware`) before billing.
|
||||
|
||||
**Why:** A blank or invented category silently breaks downstream accounting and makes humans clean up records. Incident 2026-06-30: Cascades "Windows Pro Upgrade" (product 23571919, `product_category: null`) invoiced on #32466/#32474 (invoices 67887/67890) with blank CATEGORY columns; Winter had to correct them. This keeps recurring as a skill-following gap.
|
||||
|
||||
**How to apply:** In the `/syncro` flow, before `add_line_item`/invoicing, GET the product and verify `product_category` is a real existing value. Rule lives in `.claude/commands/syncro.md` (taxable-rule block + add_line_item pre-flight). Related: [[feedback_syncro_billing]], [[feedback_syncro_preview_mandatory]], [[feedback_syncro_workflow]].
|
||||
12
.claude/memory/feedback_syncro_prepay_full_get_only.md
Normal file
12
.claude/memory/feedback_syncro_prepay_full_get_only.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback-syncro-prepay-full-get-only
|
||||
description: Syncro prepay_hours is only reliable from GET /customers/{id}; never read it from the customer search/list endpoint
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When billing in Syncro, read `prepay_hours` ONLY from the full `GET /customers/{id}` response (`.customer.prepay_hours`). The customer **search/list** endpoint (`GET /customers?query=...`) returns `prepay_hours: null` (or stale) even when the customer HAS a prepaid block. Never read prepay from a search result, and never assert "no prepaid block" / "real charge $N" in a billing preview built from search data.
|
||||
|
||||
**Why:** Repeated misfires — previews said "$300, no block," then the block surfaced during the invoice POST and the invoice netted $0 (block debited). Mike flagged this 2026-06-23 as a reliability problem that keeps recurring (Dataforth, Grabb & Durando #32455). The wrong figure in a preview the user confirms is the failure mode.
|
||||
|
||||
**How to apply:** In the billing gather step, ALWAYS `GET /customers/{id}` and pull `prepay_hours` from there BEFORE composing the preview — not just before the invoice. If you only have search/list data, fetch the full customer record first; do not guess. The /syncro skill hard rules now encode this. See [[feedback_syncro_labor_type.md]].
|
||||
14
.claude/memory/feedback_syncro_priority_type_format.md
Normal file
14
.claude/memory/feedback_syncro_priority_type_format.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: feedback_syncro_priority_type_format
|
||||
description: Syncro tickets must be created with a number-prefixed priority ("2 Normal") and a valid problem_type — bare "Normal" shows blank in the UI
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When creating ANY Syncro ticket, ALWAYS set both:
|
||||
- `priority` in the **number-prefixed** form Syncro's dropdown expects: `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`. A bare `"Normal"` does NOT match the dropdown and renders **blank** in the Syncro UI.
|
||||
- `problem_type` to a valid Issue Type (most often `Onsite` or `Remote`; others exist — Software, Hardware, File Services / Permissions, New User / Workstation Deployment, Service Request, etc.). Default to `"2 Normal"` priority unless it's an emergency/after-hours job (then `"4 Urgent"`).
|
||||
|
||||
**Why:** Winter flagged (2026-06-24) that two tickets Claude created (#32193, #32194) had priority `"Normal"` instead of `"2 Normal"`, so priority showed blank — she has to fix these by hand. "Claude knows how to do that" — the format is already documented in the `syncro` skill; the miss was not following it on create.
|
||||
|
||||
**How to apply:** Use the `syncro` skill's ticket-create flow (it documents the exact priority strings + problem-type list); never hand-roll a create that omits priority/type or uses a non-prefixed priority. Verify after create that `.ticket.priority` came back as `"N Name"`, not a bare word. See [[feedback_syncro_blank_contact]] for the companion Cascades contact rule.
|
||||
@@ -11,7 +11,7 @@ Rules only. Incident detail and ticket numbers live in [[feedback_syncro_history
|
||||
|
||||
## 1. ALWAYS preview comments before posting — no exceptions
|
||||
|
||||
Show the full comment text and wait for explicit confirmation before posting **any** comment to a Syncro ticket. No exceptions — not billing, not resolution notes, not client-facing, not internal/hidden notes.
|
||||
Canonical rule + the incident that forged it: [[feedback_syncro_preview_mandatory]] (SSOT — read it). In short: show the full comment text and wait for explicit confirmation before posting **any** comment to a Syncro ticket. No exceptions — not billing, not resolution notes, not client-facing, not internal/hidden notes.
|
||||
|
||||
**Apply:** draft → show as a formatted block → "Good to post?" → wait for yes → only then POST. Also ALWAYS ask for minutes + labor type before logging time — never assume a default.
|
||||
|
||||
|
||||
10
.claude/memory/feedback_timezone_arizona_reporting.md
Normal file
10
.claude/memory/feedback_timezone_arizona_reporting.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Feedback: Report times in Arizona time
|
||||
|
||||
**Source:** Winter, via Discord bot thread, 2026-07-02
|
||||
**Rule:** All times reported to users (Discord replies, reports, logs, tickets) are in
|
||||
Arizona time — `America/Phoenix`, MST (UTC-7) year-round, no DST. Convert API/log UTC
|
||||
timestamps before presenting and label them "AZ". Include UTC only when needed for
|
||||
cross-referencing raw logs.
|
||||
|
||||
Applied to `projects/discord-bot/DISCORD_CLAUDE.md` (formatting rules + rolling-log
|
||||
timestamp format changed from PT to AZ) same day.
|
||||
30
.claude/memory/feedback_verify_live_before_acting.md
Normal file
30
.claude/memory/feedback_verify_live_before_acting.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: feedback_verify_live_before_acting
|
||||
description: Always pull LIVE current data before acting on (or alarming about) a hardware/infra finding — wiki/session-logs are point-in-time snapshots that go stale
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Before acting on, or raising alarm about, any hardware/infrastructure state — **pull live current
|
||||
data first and lead with THAT, not the wiki or a recalled fact.** The wiki, session logs, and memory
|
||||
are point-in-time snapshots; a "broken/degraded/failing" flag may have changed by the time you read it.
|
||||
This matters most for **hard-to-reverse or money-spending actions** (drive swaps, hardware pulls,
|
||||
parts purchases, "it's down" escalations).
|
||||
|
||||
**Why:** 2026-06-24 — the Cascades CS-SERVER wiki carried a `[CRITICAL] RAID degraded / failing
|
||||
drive` flag from 2026-06-15. Acting on it, **SSDs were purchased** and Howard went onsite ready to
|
||||
hot-swap the "failing" drive. A **live Dell OMSA `omreport` query (via the RMM agent)** then showed
|
||||
the OS mirror had **self-recovered** (the flaky drive dropped out and re-synced after a power cycle):
|
||||
all 5 disks Online/Ok, all LEDs green, and the "5th unused drive" was actually the **global hot
|
||||
spare**. Acting on the 9-day-stale flag nearly pulled a healthy drive and wasted a drive purchase.
|
||||
Howard's directive: "always go with live current data to make sure our findings are real."
|
||||
|
||||
**How to apply:**
|
||||
- For Dell servers: `omreport storage controller|vdisk|pdisk controller=0` + `omreport system esmlog`
|
||||
via the RMM agent (OMSA reads the controller directly — authoritative). iDRAC/Redfish is the
|
||||
out-of-band equivalent (no iDRAC skill yet; creds not vaulted as of 2026-06-24).
|
||||
- Windows `Get-PhysicalDisk`/`Get-Disk` shows only the VIRTUAL disks as "Healthy" even when a member
|
||||
is degraded — it CANNOT see the array; never conclude RAID health from the OS view alone.
|
||||
- For any infra claim sourced from the wiki/a recalled fact: re-verify the specific file/flag/host is
|
||||
still true before recommending action. State the data's timestamp and source.
|
||||
- See [[reference_rmm_drive_map_explorer_refresh]] for the OMSA-via-RMM pattern context.
|
||||
38
.claude/memory/feedback_windows_pro_upgrade_billing.md
Normal file
38
.claude/memory/feedback_windows_pro_upgrade_billing.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: feedback_windows_pro_upgrade_billing
|
||||
description: Every machine upgraded Home->Pro with the ACG Windows Pro MAK key must be invoiced $99 to that customer, with the machine name on the line item
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
**RULE (Howard, 2026-06-24): when the ACG Windows Pro MAK key is used to upgrade a machine from
|
||||
Windows Home to Pro, invoice that customer $99 per machine — and name the machine on the line item.**
|
||||
|
||||
- The key is Mike Swanson's **Arizona Computer Guru MAK** (NOT a client key): vault
|
||||
`infrastructure/windows-pro-mak` (field `credentials.product_key`). Used fleet-wide for Home->Pro
|
||||
edition upgrades (changepk.exe flips the edition with the generic Pro key, then `slmgr /ipk <MAK>`
|
||||
+ `/ato` activates with this MAK).
|
||||
- **Billing:** one **$99** charge **per machine** activated, invoiced to the customer that owns the
|
||||
machine. The line-item description MUST name the machine (e.g. "Windows Pro upgrade - MDIRECTOR-PC").
|
||||
Bill AFTER the upgrade + activation succeed, not before. Use a flat $99 fee/product line (find or
|
||||
create a "Windows Pro Upgrade" product in Syncro; set price_retail=99 explicitly; taxable per the
|
||||
product). This is a license/upgrade fee, NOT prepaid-block labor — do not draw it from a labor block.
|
||||
- **Track activations** — each consumes one MAK count; watch the remaining allocation (exceeding it
|
||||
means contacting Microsoft to raise the count).
|
||||
|
||||
**Note:** the ACG MAK is actually a **Windows Pro for Workstations** MAK — `slmgr /ipk <MAK>` retargets
|
||||
the edition to `ProfessionalWorkstation` (a higher SKU than plain Pro, but fine for domain join). Flow that
|
||||
works remotely as SYSTEM via RMM: `changepk.exe /productkey VK7JG-NPHTM-C97JM-9MPGT-3V66T` (generic Pro key
|
||||
flips Core->Professional, no auto-reboot in SYSTEM context) -> **reboot once** to sync registry+licensing ->
|
||||
`slmgr /ipk <MAK>` -> `/ato` (retry `/ato` if it hits a transient `0x8004FE92`). Some machines self-activate
|
||||
Pro for FREE via a built-in digital entitlement after the edition flip — those consume NO MAK count and are
|
||||
NOT billed.
|
||||
|
||||
**Cascades application status (2026-06-25, done remotely):**
|
||||
- **MEMRECEPT-PC + LAPTOP-8P7HDSEI** — activated via the MAK. **INVOICED 2026-06-25** on Syncro ticket **#32466**
|
||||
(invoice #1650806091): 2 x $99 "Windows Pro Upgrade" lines (machine named on each) + 1.0h remote labor (drawn
|
||||
from Cascades' prepay block, $0) = $215.23 w/ AZ tax. Created a reusable taxable **"Windows Pro Upgrade"**
|
||||
Syncro product (id 23571919, $99) for this — use it for future Home->Pro upgrade billing.
|
||||
- **MDIRECTOR-PC** — self-activated FREE via digital entitlement; NO MAK, NO charge.
|
||||
- **NurseAssist** (offline; possible dupe of `Assistnurse-pc`) + **SALES4-PC** (Tamra departing, bypassed) — not done.
|
||||
See [[feedback_verify_live_before_acting]] for the verify-before-billing discipline.
|
||||
@@ -5,6 +5,16 @@ metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
**MECHANICAL FIX FIRST (2026-07-01): for any PowerShell payload crossing a
|
||||
mangling layer (RMM dispatch, ScreenConnect command box, plink, curl.exe args),
|
||||
use `.claude/scripts/ps-encoded.sh`** — it UTF-16LE-base64 encodes a script file
|
||||
and delivers it via `powershell -EncodedCommand` (`encode` prints the paste-safe
|
||||
one-liner; `rmm <agent-uuid> <file>` dispatches + polls via GuruRMM). Base64 has
|
||||
no quotes/backslashes/`$` to strip, so the script arrives byte-exact (verified:
|
||||
UNC `\\` survives). Author the script with the **Write tool**, not a bash heredoc
|
||||
(Git-bash heredocs collapse `\\` even single-quoted). The manual rules below
|
||||
remain for one-off inline args only.
|
||||
|
||||
On Windows, **embedded double-quotes inside a command argument get silently
|
||||
stripped or mangled** at two separate layers we hit repeatedly. The body of the
|
||||
arg survives; the `"` characters vanish, so the receiving program sees broken
|
||||
|
||||
33
.claude/memory/project_ampipit.md
Normal file
33
.claude/memory/project_ampipit.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: project_ampipit
|
||||
description: AMPIPIT (A Modular Pre-install Post-Install Tool) — Howard's active Rust+Slint Windows deploy/recovery toolkit, being migrated into ClaudeTools as a private Gitea submodule. Will fold into GuruRMM.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
AMPIPIT is Howard's active Rust + Slint portable Windows deployment/recovery toolkit (single .exe,
|
||||
runs live + in WinPE/WinRE). Currently at `C:\AMPIPIT` as its own git repo; Phases 0-5 code-complete
|
||||
(832+ tests), Phase 6 hardware validation pending. NOT merely a "reference program" — it is a real
|
||||
project under active development (supersedes the stale framing in [[project_masterbooter]] which
|
||||
lists it as reference-only).
|
||||
|
||||
**Migration in progress (2026-06-23):** bringing AMPIPIT into ClaudeTools as a **git submodule** at
|
||||
`projects/msp-tools/ampipit`, matching the guru-rmm / guru-connect pattern (own repo on the ACG
|
||||
Gitea `git.azcomputerguru.com`, referenced as a submodule). Active feature: a recovery partition
|
||||
with the **GuruRMM agent + built-in repair-script library** baked in, so offline/broken Windows can
|
||||
phone home for repair (Claude diagnoses via RMM, directs repair scripts — no direct shell). Spike
|
||||
verdict GO-WITH-WORK. Design slice: `C:\AMPIPIT\docs\RECOVERY_RMM_DESIGN_SLICE.md`.
|
||||
|
||||
**Why / hard constraints (Howard, 2026-06-23):**
|
||||
- **Do NOT lose any of the existing work** — preserve full git history; back up to a verified second
|
||||
remote (Gitea) BEFORE removing anything; remove `C:\AMPIPIT` only after full verification.
|
||||
- **No public GitHub.** Howard is deleting the old public `github.com/Howweird/AMPIPIT` repo. AMPIPIT
|
||||
must live PRIVATE on the ACG Gitea only. Drop the github remote after Gitea is verified.
|
||||
- **Follow Mike's rules for Guru projects.** AMPIPIT already encodes the 3rd-party rule as ADR-047
|
||||
(first-party core; non-MS binaries only via the PE tool registry / RemoteAccessProvider / RmmAdapter
|
||||
traits). The GuruRMM agent rides that pluggable path, never hardcoded into core.
|
||||
|
||||
**How to apply:** When working on AMPIPIT, root the session in the AMPIPIT repo dir so its own
|
||||
`CLAUDE.md`, coding/code-review/security-review agents, and the `ampipit-build` skill load. Use the
|
||||
`ampipit-build` skill. Sensitive-domain changes (BCD/partition/WIM/USMT/recovery partition) require
|
||||
the coding -> code-review -> security-review chain.
|
||||
32
.claude/memory/project_av_migration_bitdefender_to_edr.md
Normal file
32
.claude/memory/project_av_migration_bitdefender_to_edr.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: project_av_migration_bitdefender_to_edr
|
||||
description: AV strategy — migrate all clients from Bitdefender to Datto EDR; ONLY exception is Glaztech
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Standing AV direction (Howard 2026-07-03, scope narrowed 2026-07-04): ACG is moving
|
||||
endpoint AV/security from **Bitdefender GravityZone -> Datto EDR** for **all clients
|
||||
EXCEPT Glaz-Tech Industries (glaztech)** — the ONLY client staying on Bitdefender.
|
||||
**Dataforth migrates fully** (originally excepted; Howard removed the exception
|
||||
2026-07-04 — Dataforth already runs 51 EDR agents with only 5 BD endpoints left:
|
||||
D1-ENGI-006, DESKTOP-L2LE31M, DATAFORTH-PC, SURFACEOPS, MING-HP).
|
||||
|
||||
**Why:** consolidate on Datto EDR as the security plane; Bitdefender is being retired
|
||||
fleet-wide. Glaztech keeps its large established Bitdefender footprint (~242 BD
|
||||
endpoint records, vs 159 GPS-billed — count includes stale ghosts).
|
||||
|
||||
**How to apply:** whenever setting up or reconciling a client's endpoints (e.g. the
|
||||
GPS->GuruRMM coverage audit), the target end-state per machine is: GuruRMM agent +
|
||||
Datto EDR agent (AV on), and Bitdefender **removed**. Do NOT deploy new Bitdefender
|
||||
coverage. Use existing Bitdefender inventory only as a discovery source for which
|
||||
machines exist (its company names carry the Syncro CID `_NNNNN` suffix — exact join
|
||||
key to Syncro customers). Deploy Datto EDR via `[[datto-edr]]` (create-group ->
|
||||
mint-key -> deploy-cmd, pushed through `/rmm`).
|
||||
|
||||
Migration scope quantified 2026-07-04 (tracker Phase 4): 27 clients / 141 BD
|
||||
endpoints + Dataforth's 5. Datto EDR "Default RMM Org" holds ~35 unassigned agents
|
||||
that belong to real clients (IMC x7, Russo x2, Len's, Rednour, Reliant, etc.) —
|
||||
attribute them to proper orgs as part of the migration.
|
||||
|
||||
Related: GPS->RMM audit tracker `projects/gps-rmm-audit/tracker.md`.
|
||||
27
.claude/memory/project_cascades_carf_tech_plan.md
Normal file
27
.claude/memory/project_cascades_carf_tech_plan.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: project_cascades_carf_tech_plan
|
||||
description: Cascades technology plan is a CARF accreditation deliverable, not an MSP status report
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
The Cascades of Tucson "technology plan" Ashley Jensen requested (2026-06) is for **CARF
|
||||
accreditation** (Commission on Accreditation of Rehabilitation Facilities, Aging Services
|
||||
program). Her agenda list maps to CARF's **Technology and System Plan** standard (Section 1
|
||||
"CARF Plans"; the 8 canonical areas: hardware, software, security, confidentiality, backup,
|
||||
assistive technology, disaster recovery, virus protection).
|
||||
|
||||
**Why it matters:** the document is **Cascades' plan, adopted by their leadership** (ACG is the
|
||||
IT partner who supplies the technical content), NOT an ACG sales/status doc. CARF surveyors
|
||||
check it as a **living action document** that, for each area, lists: current tech + unmet/
|
||||
projected needs + timeline + possible vendor + estimated/actual cost + person responsible +
|
||||
target date + completion date. Must be **based on needs of persons served/personnel/stakeholders**,
|
||||
**aligned to the strategic plan**, and **reviewed/updated at least annually with dated sign-off**.
|
||||
|
||||
**How to apply:** any Cascades tech-plan deliverable must use the CARF action-plan structure
|
||||
(owner/cost/dates columns), include a resident/persons-served assistive-technology dimension,
|
||||
a backup-policy section, and a version/annual-review block. CARF passes on a credible REVIEWED
|
||||
plan, not on zero gaps. Verify exact standard citation + review cadence against their current
|
||||
Aging Services Standards Manual (2025/2026 edition). Deliverables live in
|
||||
`clients/cascades-tucson/docs/proposals/`. Pairs with [[feedback_impeccable_on_outbound]],
|
||||
[[policy_pricing_verification]] (cost figures must be verified).
|
||||
69
.claude/memory/project_cascades_network_segments.md
Normal file
69
.claude/memory/project_cascades_network_segments.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: project_cascades_network_segments
|
||||
description: Cascades of Tucson network segments + the CSC-ENT Wi-Fi SMB block that stalls NAS->CS-SERVER user migration
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Cascades of Tucson NAS->CS-SERVER share migration — network reality discovered 2026-06-25.
|
||||
|
||||
**CS-SERVER** (DC, RMM agent, Synology Drive sync target): IPs `192.168.2.248` (Ethernet) and
|
||||
`192.168.2.254` (Hyper-V vSwitch), both **/22 = `192.168.0.0/22`** (covers `.0`–`.3.255`).
|
||||
Domain `CASCADES` / `cascades.local`. SMB healthy — serves 13+ live sessions; shares under
|
||||
`D:\Shares\<name>` (Server, Management, Activities, Sales, etc.). Firewall SMB-In = Allow/Any.
|
||||
|
||||
**Working SMB clients** live on `10.0.20.x` (routed, gw `10.0.20.1`) and `192.168.3.x` (on-link
|
||||
in the /22). Wi-Fi SSID for corporate = **`CSCNet`**.
|
||||
|
||||
**Two Wi-Fi SSIDs (same AP family):** **`CSC ENT`** = `192.168.2.0/24`, gw `192.168.0.1`, **WPA2-PSK**
|
||||
(old NAS-side); **`CSCNet`** = `10.0.20.0/24`, gw `10.0.20.1`, **WPA3-SAE** (newer corporate). DNS =
|
||||
pfSense `192.168.0.1`. CSCNet PSK + CSC ENT PSK vaulted (`clients/cascades-tucson/wifi-cscnet.sops.yaml`,
|
||||
`wifi-csc-ent.sops.yaml`).
|
||||
|
||||
**CORRECTION x2 (2026-06-26): the SMB problem is NOT CSC-ENT-vs-CSCNet AND NOT the AV.** After a
|
||||
FULL removal of the Datto stack from CS-SERVER (DattoAV SDK + Datto RMM + Datto EDR — see below) and
|
||||
a reboot, **error 67 persisted unchanged** — so the AV minifilters (`rtp1`/`rtp2`/`BdSentry`) were a
|
||||
RED HERRING for SMB. More importantly, the SMB **server is HEALTHY for domain/Kerberos clients**:
|
||||
`Get-SmbSession` shows 7+ live SMB 3.1.1 sessions with open files (lauren, Sharon, Crystal @10.0.20.205,
|
||||
Megan, Ashley, chris) established AFTER the reboot. The failure is confined to **workgroup/NTLM +
|
||||
loopback**: Karen (DESKTOP-LPOPV30, workgroup) gets error 67 to EVERY share (IPC$, C$, a brand-new
|
||||
test share) even in her real user session with CORRECT creds, and `Get-SmbConnection`=none (the SMB
|
||||
session never negotiates; NO SMBServer/SMBClient event is logged -> failure is **pre-SMB-negotiate,
|
||||
BAD_NET_NAME**, not auth). NTLM restriction is NOT set (`RestrictReceivingNTLMTraffic`,
|
||||
`LmCompatibilityLevel`, NTLM Operational log all clear). `Get-SmbServerConfiguration` now works
|
||||
(NullSessionShares REG type was repaired prior session).
|
||||
|
||||
**The AV was DattoAV, not GravityZone Bitdefender.** It was the Datto EDR "Endpoint Protection SDK"
|
||||
under `C:\Program Files\infocyte\agent\dattoav`, managed by Datto RMM (CentraStage/CagService) + Datto
|
||||
EDR Agent (HUNTAgent/Infocyte HUNT). That is why removing from the GravityZone console did nothing.
|
||||
ALL Datto software was removed from CS-SERVER 2026-06-26 (services deleted, dirs/registry/drivers gone).
|
||||
Tenant azcomp4587; Cascades org `2d5ea96e-3228-461b-9c60-13ae464b61d8`; CS-SERVER was already de-enrolled
|
||||
from EDR. Use the `/datto-edr` skill + `msp-tools/datto-edr.sops.yaml`.
|
||||
|
||||
**RESOLVED (2026-06-26): there was NO CS-SERVER SMB problem. "Error 67" was a TEST-METHOD ARTIFACT.**
|
||||
RMM-dispatched SMB client commands (`net use`/`net view`/`Test-Path`/`Get-SmbConnection`, even with
|
||||
`context: user_session`) FAIL with error 67 / RPC 1702 / "none" for KNOWN-GOOD targets too — proven:
|
||||
Karen's RMM test to her daily-use NAS (`\\CASCADESDS`) returned the same errors, and Crystal showed a
|
||||
live server-side session (5 open files) while RMM `Get-SmbConnection` on her box reported none. The
|
||||
agent-injected process lacks the user's real network-logon session. **Validate SMB with server-side
|
||||
`Get-SmbSession` (showed 7 live users / 30 open files / new sessions forming) or a REAL INTERACTIVE
|
||||
test — never RMM-dispatched client cmds.** Verified 2026-06-26: logging into CS-SERVER shares as
|
||||
`CASCADES\karen.rossini` interactively from another PC (John Trozzi/MAINTENANCE-PC) WORKED — account,
|
||||
password (vaulted), `SG-IT-RW` access, and the server are all fine. The original drive-map `verify`
|
||||
failure that started this whole investigation was the same RMM artifact. See errorlog friction entry
|
||||
`rmm/smb-testing`. Karen's only remaining real item: repoint her ALDocs shortcut on DESKTOP-LPOPV30 to
|
||||
`\\CS-SERVER\Server\ALDocs` and CONFIRM INTERACTIVELY (don't trust drive-map's RMM verify). NOTE: the
|
||||
prior-session move of Karen to CSCNet broke her NAS-by-name resolution — unrelated to the (non)issue.
|
||||
|
||||
**Two more migration blockers found:** (1) **CSCNet is WPA3-SAE** — older adapters (e.g. Intel AC 3165
|
||||
on Meredith's ASSISTMAN-PC) **cannot join it**, so "move everyone to CSCNet" is blocked by hardware.
|
||||
(2) **Security smell:** workstations are WORKGROUP (local logins) and reach CS-SERVER by storing a
|
||||
DOMAIN credential — Meredith's PC stores `cmdkey cs-server→administrator` (her Y:/X:/E: are mapped as
|
||||
domain admin). Shares `Server`/`Management` = share ACL **Authenticated Users:Full** (NTFS gates real
|
||||
access). Fix: stop storing admin creds on user PCs; scope shares to groups.
|
||||
|
||||
Karen Rossini case (DESKTOP-LPOPV30, WORKGROUP, dual Wi-Fi): `CASCADES\karen.rossini` reset+vaulted
|
||||
(`clients/cascades-tucson/karen-rossini.sops.yaml`), added to `SG-IT-RW`, CS-SERVER cmdkey staged.
|
||||
She doesn't really use the Server folder (per Howard) so deprioritized. ALDocs is in
|
||||
`\\CS-SERVER\Server\ALDocs`. Repoint tooling = [[drive-map]] skill (but blocked by the CS-SERVER SMB
|
||||
instability above — fix that first).
|
||||
84
.claude/memory/project_cascades_vlan20_migration_routing.md
Normal file
84
.claude/memory/project_cascades_vlan20_migration_routing.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: project_cascades_vlan20_migration_routing
|
||||
description: Cascades CSC ENT->VLAN20 migration — pfSense WAN_Group policy-route breaks LAN<->VLAN20; fix + printer-migration mechanics
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Cascades is migrating staff machines + printers off the flat old LAN (192.168.0.0/22,
|
||||
"CSC ENT") onto the isolated **Staff VLAN 20 (10.0.20.0/24, gw 10.0.20.1)** ("CSCNET").
|
||||
Printers are being re-IP'd to 10.0.20.x (static) and re-shared on the CS-SERVER print
|
||||
server.
|
||||
|
||||
**PROGRESS (live-reconciled 2026-07-01):** MACHINES are essentially done — 22 online hosts on
|
||||
VLAN 20; only CS-SERVER (stays by design) + ~6 stragglers (ASSISTMAN-PC, CascadesProxess,
|
||||
Laptop2, NurseAssist, 2 roaming laptops) remain on 192.168.x. PRINTERS lag — only 4 of 15
|
||||
CS-SERVER shares repointed to 10.0.20.x (FrontDesk .221, BusinessOffice .220, LifeEnrichment
|
||||
.94, MCReception .78); 11 shares still on old-LAN IPs (MCMedTech still stale 192.168.2.53 though
|
||||
target 10.0.20.74 is live+reachable). GPO still NOT fleet-live (silent new-driver-install gap
|
||||
open: reboot vs pre-stage drivers). MEMCARE-STATION rename staged but not yet applied. Full
|
||||
live inventory (all shares/ports/machines): clients/cascades-tucson/docs/printer-gpo-map.md.
|
||||
|
||||
Key operational facts learned 2026-06-30 (Howard, front-desk ET-5800 + Life
|
||||
Enrichment Canon MF741CDW):
|
||||
|
||||
**pfSense gotcha (the big one):** CS-SERVER (on the old LAN) could not reach ANY VLAN 20
|
||||
printer (.221/.220/.94/.78:9100) even though it pinged the VLAN20 gateway 10.0.20.1. Root
|
||||
cause was NOT a block — the LAN "Default allow LAN to any" rule has **Gateway = WAN_Group**
|
||||
(dual-WAN policy routing), so LAN->internal-VLAN traffic gets shoved out the WAN and dies.
|
||||
**Fix = a pass rule at the TOP of the LAN interface** (Firewall/Rules/LAN), Source =
|
||||
CS-SERVER 192.168.2.248, Dest = 10.0.20.0/24, protocol any, **Gateway = default** (do NOT
|
||||
set WAN). This bypasses the policy route so internal traffic routes normally. Scoped to the
|
||||
server's source IP => residents (own /28 VLANs) + guests (VLAN 50) can't match it (rule is
|
||||
on the LAN interface, sourced from the server only). This also un-broke the already-migrated
|
||||
Business Office/Life Enrichment/MC Reception shares. VLAN20->server (SMB) was already fine.
|
||||
|
||||
**pfSense SSH from the VPN is BLOCKED** (tcp/22 dropped; GUI 443 open). The `unifi-wifi`
|
||||
skill's `pfsense-ssh.sh` therefore returns empty (it sends ssh stderr to /dev/null). Did the
|
||||
rule via the GUI instead. To use the skill remotely later, add an OpenVPN-side allow for 22.
|
||||
|
||||
**Printer migration mechanics:**
|
||||
- CS-SERVER side: repoint the share's port to TCP_10.0.20.<x>:9100 (Set-Printer -PortName),
|
||||
drop the old 192.168.2.x port; keep the same ShareName so client mappings survive.
|
||||
- Client side: mapping `\\CS-SERVER\<share>` as a standard domain user triggers a
|
||||
PrintNightmare elevation prompt (HRESULT 0x800702e4) EVEN when the driver is already local
|
||||
— see [[feedback_rmm_printer_elevation]] / errorlog. Promptless options: GPO printer
|
||||
deployment (they already do this for caregivers — the scalable answer), or push as SYSTEM
|
||||
via `rundll32 printui.dll,PrintUIEntry /ga /n"\\CS-SERVER\<share>"` (per-machine, appears
|
||||
at the user's NEXT logon), or set Point-and-Print "Approved server = CS-SERVER" so user-
|
||||
context maps are promptless+immediate.
|
||||
- Old room-named shares (e.g. `1F-132-RecRoom-Canon`) were renamed on the server during
|
||||
migration, leaving ORPHANED per-user client mappings; a spooler restart auto-drops them.
|
||||
- Build UNC paths in RMM PowerShell with `[char]92`, not literal `\\` (jq/agent pipeline
|
||||
mangles literal backslashes — [[feedback_windows_quote_stripping]]).
|
||||
|
||||
**Point-and-Print is the REAL promptless fix (proven 2026-06-30 on the LE machines).** The
|
||||
0x800702e4 prompt AND the `/ga` per-machine path silently failing at logon (PrintService
|
||||
event 513, error 0xBCB) are BOTH the same default `RestrictDriverInstallationToAdministrators`
|
||||
(ON when unset) blocking the standard user from pulling the driver. We're domain admin, but
|
||||
the *end user* isn't — so apply admin rights via the Point-and-Print policy:
|
||||
`HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Printers` `RestrictDriverInstallationToAdministrators=0`
|
||||
+ subkey `PointAndPrint` `Restricted=1,TrustedServers=1,ServerList=CS-SERVER,InForest=0,`
|
||||
`NoWarningNoElevationOnInstall=1,UpdatePromptSettings=2` (scopes silent install to CS-SERVER
|
||||
only). After that, `WScript.Network.AddWindowsPrinterConnection` in the user session is
|
||||
promptless+immediate. **Correct durable fix = put this in a computer GPO fleet-wide** (the
|
||||
caregiver machines already have it; that's why their printer GPO works), then deploy printers
|
||||
via GPO. Existing GPOs: `CSC - Life Enrichment Printers`, `CSC - Printer Deployment`,
|
||||
`CSC - Caregiver Workstation`, `CSC - Reception Workstation Policy` (the LE one likely still
|
||||
pushes the OLD share name — repoint it to the new share).
|
||||
|
||||
**Driver/PDL trap — Canon MF741/743 = UFR II ONLY (not PCL).** The rebuilt `LifeEnrichment`
|
||||
share was created with **Canon Generic Plus PCL6**; the MF741 can't parse PCL → spools OK,
|
||||
nothing prints, panel shows **Error #822** (unsupported/corrupt data). Fix = use the **UFR II**
|
||||
driver (`Canon Generic Plus UFR II V250`, INF cnlb0ma64.inf). CS-SERVER only had PCL6/PS3/XPS
|
||||
staged; pulled UFR II from a client's DriverStore (`C:\Windows\System32\DriverStore\
|
||||
FileRepository\cnlb0ma64.inf_amd64_*`) using the vaulted `cs-server` `sysadmin` domain-admin
|
||||
cred. **Transfer direction matters:** CS-SERVER (192.168.2.x) CANNOT reach a client's C$
|
||||
(client host-firewall scopes File/Print sharing to LocalSubnet, and CS-SERVER is off-subnet)
|
||||
-> have the CLIENT push to `\\CS-SERVER\C$` instead (client->server SMB works). Then
|
||||
`pnputil /add-driver <inf> /install`, `Add-PrinterDriver -Name "<exact INF model name>"`,
|
||||
`Set-Printer -DriverName`. Get the exact driver name from the INF's quoted strings, not a guess.
|
||||
When the server driver changes, refresh each client connection (Remove+AddWindowsPrinterConnection)
|
||||
so it drops the stale cached PCL6 driver.
|
||||
|
||||
Related: wiki clients/cascades-tucson (network/VLANs), [[project-cascades-migration-plan]].
|
||||
16
.claude/memory/project_gururmm_dispatch_hang_fix.md
Normal file
16
.claude/memory/project_gururmm_dispatch_hang_fix.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: project_gururmm_dispatch_hang_fix
|
||||
description: GuruRMM fleet-wide command-dispatch hang root cause + fix (send_to try_send, 9dae20c) and the still-missing eviction
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
On 2026-06-22 the live GuruRMM server (`172.16.3.30:3001`) hung on **every** `POST /api/agents/:id/command` (30s+ timeouts, all agents; GET worked) — command dispatch was down fleet-wide.
|
||||
|
||||
**Root cause:** `AgentConnections::send_to` (server `src/ws/mod.rs`) did a blocking `tx.send(msg).await` on a bounded (cap 100) per-agent mpsc channel. A black-holed/half-open agent socket stops its WS writer draining the channel → it fills → `send().await` blocks forever. `send_command` holds `state.agents.read().await` across that await, so the next agent (re)connect's `.write().await` starves tokio's write-preferring `RwLock`, queuing all later dispatches behind it. **One dead socket wedged the whole fleet.** The recovery path "evict non-delivering connections" (`7c578fd`) had been **reverted** (`80df458`), leaving no escape hatch.
|
||||
|
||||
**Fix (`9dae20c`, on `main`, deployed):** `send_to` now uses non-blocking `try_send` — a full/closed channel returns "not delivered"; the command stays persisted and is re-offered by `redispatch_pending_commands` (reconnect) + the reaper `requeue_undelivered_commands`. Failure stays local. Verified live (other agent ran a command end-to-end in ~5s).
|
||||
|
||||
**Still open / watch:** the proper per-connection eviction of a black-holed socket is still absent (only reverted code existed). A truly half-open agent will keep heartbeating `online` while its server→agent channel silently drops messages (commands dispatch as `running` but never return → reaper fails them on timeout). If this recurs, finish the eviction/keepalive-drop work rather than relying on `try_send` alone.
|
||||
|
||||
Deploy model: merging to `gururmm` `main` triggers the webhook build on `.30` (rebuild + `systemctl` restart, auto-rollback if the binary won't start). See the `gururmm-build` skill. Pairs with [[project_guruscan_in_test_paused]].
|
||||
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal file
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: project_gururmm_legacy_disabled
|
||||
description: GuruRMM's legacy Rust 1.77 (Server 2008R2 / Win7) build variant is DISABLED as of 2026-07-04 — do not just re-enable it; 2008R2 support returns via a separate legacy agent (C++/.NET or clean rewrite)
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
The GuruRMM Windows build's **legacy Rust 1.77 variant is disabled** (2026-07-04) via
|
||||
`LEGACY_ENABLED=false` in `deploy/build-pipeline/build-windows.sh`.
|
||||
|
||||
**Why:** the legacy build moves `Cargo.lock` aside and re-resolves deps unpinned; crates.io now
|
||||
serves edition2024 versions of transitive deps (`chacha20`/`rand_core` 0.10.1) that Rust 1.77
|
||||
cannot parse ("feature `edition2024` is required"). This fail-closed the ENTIRE Windows build and
|
||||
blocked modern (amd64/x86/tray) deploys. It is a **BUG-021 recurrence** and is NOT caused by any
|
||||
agent code change — it reproduces at any base commit right now. This is at least the 3rd edition2024
|
||||
break of the 1.77 build (the `agent/Cargo.toml` transitive-pin list is a monument to it).
|
||||
|
||||
**What the disable does:** skips the legacy wave/copy/deploy/symlink and EXCLUDES legacy from the
|
||||
artifact cleanup, so the last-built legacy binaries (**0.6.76**) are PRESERVED and existing Server
|
||||
2008R2 / Win7 endpoints stay supported *frozen at 0.6.76*. Modern agents advance normally.
|
||||
|
||||
**Mike's directive (2026-07-04):** "Kill the legacy version for now, but we will come back to it" —
|
||||
2008R2 support is REQUIRED (a number are still in the wild) and will return via a **different
|
||||
architecture (C++/.NET) or a clean rewrite for legacy**, NOT by flipping `LEGACY_ENABLED` back on
|
||||
without first solving the 1.77 edition2024 pin problem. Do NOT re-enable it blindly. Tracked in
|
||||
`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`. Related: the policy-controlled tray feature that
|
||||
was shipping when this surfaced ([[project_guruconnect]] is a different project).
|
||||
16
.claude/memory/project_gururmm_security_scope.md
Normal file
16
.claude/memory/project_gururmm_security_scope.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: GuruRMM security scope — integrate AV, don't replace it
|
||||
description: GuruRMM product scope on security/AV — the RMM does NOT build native virus/malware removal; it integrates AV products (monitor their reports + send commands to them) and its own built-in value is helping techs FIND issues. Program/software removal is a separate, distinct feature.
|
||||
type: project
|
||||
---
|
||||
|
||||
Product-direction decision (Mike, 2026-06-22). When weighing security/diagnostic features for GuruRMM:
|
||||
|
||||
- **No native AV / virus / malware removal in the RMM.** Dedicated AV products (Bitdefender GravityZone, Datto EDR/AV — see [[reference_acg_msp_stack]]) do that work. Don't pitch building a RogueKiller-style scanner/quarantine engine into the agent.
|
||||
- **The RMM's AV role is integration:** monitor/surface the AV products' reports + status, and send commands/actions to those AV products *through* the RMM. Manage AV, don't be AV.
|
||||
- **The RMM's own built-in value is helping techs FIND issues** — diagnostics, health surfacing, "what's wrong with this box" tooling — not performing endpoint security remediation itself.
|
||||
- **Program/software removal is a DISTINCT feature** (the ARP-registry silent-uninstall engine, SPEC-030 `remote-software-uninstall`), unrelated to AV. It was being worked in a separate session as of this date.
|
||||
|
||||
**Why:** avoids reinventing mature AV engines, keeps the RMM RMM-first (mission.md non-goals), and plays to the self-hosted-management strength rather than competing with security vendors.
|
||||
|
||||
**How to apply:** for security-flavored feature ideas, frame as "monitor + command the existing AV/security product" or "help the tech locate the problem," not "build the security capability natively." Related: [[project_gururmm]], [[feedback_no_manufactured_guardrails]].
|
||||
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal file
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: project_gururmm_software_removal_pr58
|
||||
description: SPEC-030 software-removal hardening (driver exclusion, lingering-process sweep, job list/reaper) is on PR #58 awaiting merge; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
GuruRMM SPEC-030 software removal (Assets -> Inventory tab): the 2026-06-24 session's open
|
||||
requests were implemented 2026-07-05 and sit on branch `feat/software-removal-hardening`,
|
||||
**PR #58** (https://git.azcomputerguru.com/azcomputerguru/gururmm/pulls/58), NOT merged —
|
||||
merge deploys to beta. Live testing planned 2026-07-06 on the test box DESKTOP-MS42HNC
|
||||
(agent 0de89b88-b21d-4647-ab64-96157ba87cc5).
|
||||
|
||||
What PR #58 contains:
|
||||
- Engine `-List` emits `is_driver` (heuristic: driver/chipset/firmware name words, or hw-vendor
|
||||
publisher + device words; junkware like "Driver Booster" deliberately excluded). Dashboard
|
||||
hides drivers by default, keeps them out of select-all, deselects on re-hide.
|
||||
- End-of-run lingering-uninstaller sweep in uninstall-engine.ps1: 45s budget-aware grace, then
|
||||
taskkill of started trees (StartTime PID-reuse guard, same-session only, null CreationDate
|
||||
excluded) + late re-verify that flips async-finisher false-failures to success. This is the
|
||||
chosen resolution of the Launchy/AIMP "failed / no usable result" open decision.
|
||||
- `GET /agents/:id/software/uninstall/jobs` (list, results omitted), stale-running-job reaper
|
||||
(900s no-progress -> failed), `finish_job` guarded WHERE status='running', UI 100-target cap.
|
||||
|
||||
Verified pre-merge: engine live tests on Howard-Home (driver flags, sweep kill/no-kill,
|
||||
orphaned/refused paths), heuristic unit cases, `verify.sh server --check` + `dashboard` PASS,
|
||||
high-effort workflow code review with all confirmed findings fixed.
|
||||
|
||||
Remaining after this: recipe catalog (P3, [[project_gururmm_av_removal_recipes]] if created),
|
||||
dashboard UI for the new job-list endpoint (client binding `softwareApi.uninstallJobs` exists,
|
||||
unused), and TeamViewer retry-verify still not re-validated live.
|
||||
17
.claude/memory/project_guruscan_in_test_paused.md
Normal file
17
.claude/memory/project_guruscan_in_test_paused.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: project_guruscan_in_test_paused
|
||||
description: GuruScan multi-engine scan verification is IN TEST / paused on DESKTOP-MS42HNC (resume steps + state)
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
GuruScan (multi-engine malware scanner, `projects/msp-tools/guru-scan`, hardened at `fb09102`) is **IN TEST — PAUSED** as of 2026-06-22. Verifying full-scan + full-removal + automated lifecycle on test VM **DESKTOP-MS42HNC** (agent id `0de89b88-b21d-4647-ab64-96157ba87cc5`, client AZ Computer Guru / site Howard-VM — a flaky laptop VM that sleeps/reboots).
|
||||
|
||||
State:
|
||||
- **HitmanPro** lifecycle already verified (36 threats detected+removed, reboot-cleanup task fires).
|
||||
- **Emsisoft** full `/f=C:\` run (the heavy ~80-min engine) was launched with 516 samples staged but **interrupted by a VM reboot — NOT yet verified**. This is the open item.
|
||||
- Resume hands-off: `bash .claude/scripts/guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft` (self-restores samples, detached no-cap, reports removal + results.json + cleanup task).
|
||||
|
||||
Cleanup still owed on the VM once testing is done: remove samples/zip/EICAR/test tasks, clear scanner quarantine, and **re-enable Windows Defender RTP + Tamper Protection** (disabled at the console for malware testing).
|
||||
|
||||
Blocker that surfaced + was fixed this session: a fleet-wide RMM command-dispatch hang — see [[project_gururmm_dispatch_hang_fix]].
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: tech03l-systemprofile-shortcut-corruption
|
||||
description: ACG-TECH03L shortcuts got rewritten to nonexistent systemprofile paths (Auto-Claude "opens then closes"); repointed via RMM 2026-07-04
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
On ACG-TECH03L (Howard's laptop, "tech-03"), the desktop + Start Menu **Auto-Claude**
|
||||
shortcuts and the **UltraSearch** shortcut were found pointing at
|
||||
`C:\Windows\system32\config\systemprofile\...` paths that do not exist — symptom was
|
||||
"ClaudeTools opens and then closes" when Howard double-clicked the launcher. The Claude
|
||||
Code CLI itself was healthy (2.1.160, node v24.16.0, repo + identity.json intact).
|
||||
|
||||
**Why:** something (likely an installer/updater running under SYSTEM context, e.g. via
|
||||
RMM) resolved `%LOCALAPPDATA%`/`%USERPROFILE%` to the SYSTEM profile when writing the
|
||||
shortcuts. If a Claude-adjacent launcher "flashes and closes" on a fleet machine, check
|
||||
the .lnk TargetPath for `systemprofile` FIRST before debugging the app.
|
||||
|
||||
**How to apply:** repoint the .lnk to the real per-user install
|
||||
(`C:\Users\<user>\AppData\Local\Programs\auto-claude-ui\Auto-Claude.exe`) via
|
||||
WScript.Shell as SYSTEM — fixed 2026-07-04 (RMM cmd 0c9180df). UltraSearch was left
|
||||
broken: no user-profile copy exists on disk; needs reinstall if Howard wants it.
|
||||
Avoid running per-user app installers/updaters from SYSTEM context.
|
||||
25
.claude/memory/ps51_invoke_restmethod_headers_variable.md
Normal file
25
.claude/memory/ps51_invoke_restmethod_headers_variable.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: ps51-invoke-restmethod-headers-variable
|
||||
description: PS5.1 via RMM powershell -File - Invoke-RestMethod -Headers $H (script-scope hashtable) sent an EMPTY Authorization header; build headers inline per call instead
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
On ACG-DWP-X-BB (WS2019, PowerShell 5.1, script executed via GuruRMM agent as
|
||||
`powershell -NoProfile -File script.ps1`), passing a script-scope hashtable variable to
|
||||
`Invoke-RestMethod -Headers $H` (where `$H=@{Authorization="Bearer $tok"}`) resulted in
|
||||
Graph rejecting every call with `InvalidAuthenticationToken / "Access token is empty"` —
|
||||
even though the token variable was verifiably populated (fingerprint printed correctly)
|
||||
and the same token + same URL worked with an inline-built header from the same machine.
|
||||
|
||||
**Fix that works:** build the header at each call site — e.g.
|
||||
`function Hdr { return @{Authorization=("Bearer " + $script:tok)} }` then
|
||||
`Invoke-RestMethod -Uri $u -Headers (Hdr)`. Inline `@{Authorization=('Bearer '+$t)}` also works.
|
||||
|
||||
Diagnosed 2026-07-02 during the BirthBio Datto-vs-SharePoint reconciliation after two
|
||||
full failed runs (masqueraded first as token expiry, then as clock skew — the real tell
|
||||
was the Graph error body "Access token is empty", captured only after adding response-body
|
||||
extraction to the retry helper). Always capture the Graph error BODY, not just the
|
||||
exception message: "(401) Unauthorized" alone cost three debugging cycles.
|
||||
|
||||
Related: [[gururmm-command-timeout-seconds]], [[sharepoint-graph-large-file-upload]]
|
||||
32
.claude/memory/reference_365_app_suite.md
Normal file
32
.claude/memory/reference_365_app_suite.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: reference_365_app_suite
|
||||
description: Authoritative map of the ComputerGuru M365 app suite (apps, App IDs, live-verified permissions per tier) and — the recurring failure — per-tenant consent is NOT uniform; how to audit + fix partial consent.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The ComputerGuru M365 app suite is fully documented in the remediation-tool skill:
|
||||
`.claude/skills/remediation-tool/references/app-suite.md` (authoritative; live-verified
|
||||
2026-07-02). Read it before concluding "the tool can't do X on tenant Y".
|
||||
|
||||
**The recurring failure it fixes:** per-tenant consent is NOT uniform. A tenant can have an
|
||||
app's service principal but only a PARTIAL/OLD permission grant. Example: VWP
|
||||
(valleywideplastering.com, 5c53ae9f-…) had the Tenant Admin app but NO SharePoint
|
||||
`Sites.FullControl.All` — SharePoint calls 401'd with a valid-looking token whose `roles`
|
||||
claim was empty. The suite "having" a capability (baseline design) ≠ a given tenant having it
|
||||
(actual consent).
|
||||
|
||||
**Always AUDIT before giving up:** decode each tier's token `roles` on the target tenant and
|
||||
compare to the baseline in app-suite.md. Empty roles on a correct `aud` = present-but-not-granted.
|
||||
|
||||
**Fix partial consent — two methods:**
|
||||
- A: re-consent the whole manifest — `https://login.microsoftonline.com/<tenant>/adminconsent?client_id=<app-id>` (reliably grants Graph; the SharePoint app-only role often does NOT attach from consent — verify + use B for the leftover).
|
||||
- B: grant the specific missing app role directly via `POST /servicePrincipals/{recipientSP}/appRoleAssignments` using a `tenant-admin` token (holds AppRoleAssignment.ReadWrite.All). This is how VWP's SharePoint role was granted 2026-07-02; propagates to a fresh token in seconds. Only to complete an intent the customer already consented to.
|
||||
- EXO role gap: `assign-exchange-role.sh <domain>` (audit fleet: `--all --verify`).
|
||||
|
||||
Apps: Security Investigator bfbc12a4 (Graph read + EXO read), Exchange Operator b43e7342
|
||||
(EXO all-access + `exchange-op-graph` Graph Mail.ReadWrite), User Manager 64fac46b (Graph
|
||||
user/group write), Tenant Admin 709e6eed (Graph high-priv + SharePoint Sites.FullControl.All
|
||||
via CERT), Defender dbf8ad1a (MDE), Intune 46986910, Mailbox 1873b1b0 (ACG-internal only).
|
||||
SharePoint app-only REQUIRES cert (not secret). See [[reference_remediation_tool_365_access]],
|
||||
[[feedback_exchange_role_recurring_gap]], [[feedback_exchange_op_all_access]].
|
||||
37
.claude/memory/reference_alis_medtelligent.md
Normal file
37
.claude/memory/reference_alis_medtelligent.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: reference_alis_medtelligent
|
||||
description: ALIS (Medtelligent assisted-living EHR) API + staff-import facts for Cascades Tucson — auth quirk, read-only staff, web-UI import path. Use the `alis` skill.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
ALIS = Medtelligent's assisted-living EHR (Cascades of Tucson client). All API traffic
|
||||
goes to the shared host **`api.alisonline.com`** (the tenant URL `cascadestucson.alisonline.com`
|
||||
is just the login subdomain), scoped by the user's company + a `communityId`. **Cascades =
|
||||
communityId 622** (the only community this credential sees). Use the **`alis` skill** — don't
|
||||
hand-roll the API.
|
||||
|
||||
**Auth (verified live 2026-06-29):** `POST /user/tokens` with `{username, password}` → JWT
|
||||
(`accessToken` ~1h) + `refreshToken`; send `Authorization: Bearer <accessToken>`. The
|
||||
**username MUST be tenant-qualified**: `howard.enos@cascadestucson` works; bare `howard.enos`
|
||||
returns HTTP 400. Login creds in vault: `clients/cascades-tucson/alis-api-howard-user`
|
||||
(Howard's password was exposed in chat 2026-06-29 — flagged to rotate). Other ALIS vault
|
||||
entries: `alis-api-microsoft-basic` (BasicAuth used by Microsoft), `alis-sso-app-registration`.
|
||||
Global API security is OR(Bearer|BasicAuth|VendorKey) — a user JWT alone authorizes reads.
|
||||
|
||||
**Staff are READ-ONLY via the API** — only GET endpoints exist (`/v1/integration/staff?communityId=622`
|
||||
etc.); no create/update/delete. **To create/change staff (and their logins) you upload a
|
||||
13-column .xls in the ALIS web UI: Staff → Import.** That import sets Login Enabled + Password,
|
||||
so it's also how staff logins are provisioned. The `alis` skill builds that workbook from a
|
||||
CSV/JSON and infers each new hire's Security Roles from how existing staff of the same Job Role
|
||||
are set up (job-role → security-role map learned from live data; 23 real security roles, Job
|
||||
Role is free text). The API *does* allow writes for residents/prospects/billing (not staff).
|
||||
|
||||
**Import format (confirmed from a real ALIS export, ALIS_Staff_Update_Import.xls):** two layouts.
|
||||
CREATE (new staff) has a Password column + NO ALIS ID — rows without an ALIS ID are created.
|
||||
UPDATE (existing staff) leads with **ALIS ID** (the staffId, the match key) + no Password. So
|
||||
present-ALIS-ID = update, absent = create. **Dates are MM/DD/YYYY.** Security Roles are
|
||||
comma-separated multi-values; the `alis` skill infers the full typical combo per job role from
|
||||
current staff. Still test ONE row first before a bulk run.
|
||||
|
||||
Related: [[reference_resource_map]], [[feedback-vault-every-credential]].
|
||||
@@ -1,12 +1,24 @@
|
||||
---
|
||||
name: reference_antigravity_agy_not_headless
|
||||
description: Antigravity CLI agy.exe is the IDE embedded agent (no stdout, SQLite store) — NOT a headless CLI. The agy skill uses @google/gemini-cli, not agy.exe. Don't reinstall agy.exe expecting a headless tool.
|
||||
description: UPDATE 2026-07-03 — agy.exe NOW works headless (v1.0.6+ --print/-p returns clean stdout). Use it as the Antigravity/Gemini second-model path. Older claim (DOA headless) is obsolete.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The `agy.exe` installed by Google's Antigravity CLI (`%LOCALAPPDATA%\agy\bin\agy.exe`, installer `https://antigravity.google/cli/install.ps1`) is the IDE's embedded agent, **NOT a usable headless CLI** on this fleet. Even v1.0.6's advertised `-p/--print` produces ZERO stdout and hangs when invoked non-interactively from the Bash/PowerShell tool harness — it writes only to a SQLite conversation store. First found 2026-06-05 (`session-logs/2026-06-05-mike-gururmm-platform-day.md` line 35); **re-confirmed 2026-06-06** after the GURU-5070 reinstall (reinstalled agy.exe and walked straight back into the same no-output/hang symptom).
|
||||
**CORRECTION 2026-07-03 (GURU-BEAST-ROG):** `agy.exe` (Google Antigravity CLI,
|
||||
`%LOCALAPPDATA%\agy\bin\agy.exe`) **now has a working headless print mode.** `agy -p "<prompt>"
|
||||
--dangerously-skip-permissions` returns clean stdout and exits 0 (smoke-tested: returned "READY";
|
||||
also drove a full multi-file spec critique reading files via `--add-dir <dir>`). Flags: `-p/--print`
|
||||
(single non-interactive prompt), `--print-timeout` (default 5m), `--add-dir` (add a workspace dir so
|
||||
it can read files), `--model`, `--dangerously-skip-permissions` (auto-approve tools). Subcommands:
|
||||
`models`, `install`, `update`, `plugin`. Re-wire came from GURU-5070 fleet sync + a newer agy build.
|
||||
So agy IS now a usable Antigravity/**Gemini second-model** path, especially when `gemini-cli` OAuth
|
||||
fails (e.g. BEAST-ROG's `throwIneligibleOrProjectIdError`). NOTE: after install the binary is added to
|
||||
User PATH registry but NOT the active shell PATH until terminal restart — call it by full path
|
||||
`/c/Users/guru/AppData/Local/agy/bin/agy.exe` from the Bash tool.
|
||||
|
||||
The `agy` SKILL (despite the name) routes to the official **`@google/gemini-cli`** (`gemini`, npm global) — that IS the real headless second-opinion tool (Google OAuth, no API key), resolved via `identity.json .gemini.binary`. Grok (`ask-grok.sh`) is the other working second model. Both were verified returning `OK` on 2026-06-06.
|
||||
|
||||
**June 18 sunset — likely a non-issue for ACG.** Google is sunsetting gemini-cli's free/unpaid OAuth quota on **2026-06-18**, but Mike has a **paid Gemini account**, so the plan is to **stay on gemini-cli** (do NOT migrate to Antigravity). The bulletproof form is to auth gemini-cli with a paid **Gemini API key** (`GEMINI_API_KEY`) rather than the free OAuth quota — that path is unaffected by the OAuth-CLI sunset regardless of how the consumer tiers shake out, and is more stable for headless use. (Sources disagree on whether paid Pro/Ultra OAuth is also cut, so the API-key path is the safe bet.) **Do NOT reinstall agy.exe expecting it to work headless.** Related: [[feedback_agy_review_not_readonly]].
|
||||
**Historical (pre-2026-07-03, now OBSOLETE):** earlier agy.exe builds (~June 2026) produced ZERO
|
||||
stdout in `-p` mode and hung when driven headless (wrote only to a SQLite conversation store), so the
|
||||
`agy` SKILL was pointed at `@google/gemini-cli` instead. The gemini-cli path still exists
|
||||
(`ask-gemini.sh`) but on machines where its OAuth is ineligible, prefer **agy -p** now. Related:
|
||||
[[reference_cdp_chrome_driver]], [[feedback_agy_review_not_readonly]].
|
||||
|
||||
23
.claude/memory/reference_datto_edr_detection_behavior.md
Normal file
23
.claude/memory/reference_datto_edr_detection_behavior.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: reference_datto_edr_detection_behavior
|
||||
description: How Datto EDR (azcomp4587) actually detects/reports, and AV-suppression gotchas — verified live on RMM-TEST-MACHINE
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Verified live 2026-06-25/26 on RMM-TEST-MACHINE (EDR agent `b98b3ba0-...`, group `[TEST] RMM-TEST-MACHINE`, org Arizona Computer Guru) via the `datto-edr` skill + `/rmm`.
|
||||
|
||||
**Alert `sourceType` taxonomy (how to tell WHICH engine fired):**
|
||||
- `av` = Datto AV signature hit (e.g. `Eicar-Test-Signature`). On-access/RTP.
|
||||
- `rule` = Datto **EDR** detection — reputation/analyst rule on the forensic scan (e.g. `Generic Malware (Reputation - High Severity)`, description "Malware detected by endpoint protection").
|
||||
- Both land in the same `Alerts` collection and surface identically via `edr.py detections`.
|
||||
|
||||
**Datto EDR is reputation-based, NOT structural.** A synthetic "looks suspicious" artifact (Run-key/scheduled task launching hidden `-EncodedCommand` powershell) is collected by the forensic scan but scored BENIGN → no alert (powershell.exe is signed/clean). To get an EDR detection you need a real reputation hit: wire a known-bad file as the **executable of an autostart** (Run-key/scheduled task) so the survey collects + hashes it. EICAR-as-autostart works → high-sev `rule` alert. A loose file on disk is NOT scanned by the EDR forensic survey (it only walks execution/persistence artifacts).
|
||||
|
||||
**AV-suppression gotchas (to isolate EDR on an endpoint):**
|
||||
- Datto AV is **tamper-protected**: `Stop-Service EndpointProtectionService2 -Force` is refused ("cannot be stopped"); can't disable from the endpoint. Supported path = console policy (AV disabled / path-exclusion) — console-only, like policy assignment.
|
||||
- Disabling Datto AV in the policy **uninstalls** the AV component on the box (services `EndpointProtectionService`/`...2` go absent; `HUNTAgent` EDR stays). Platform `dattoAvEnabled` flips to False at the console first; the on-box apply lags a few minutes.
|
||||
- Removing Datto AV makes **Windows Defender auto-reactivate** (Security Center turns it back on when no 3rd-party AV registered). Then Defender RTP quarantines EICAR AND its **AMSI blocks any PowerShell script containing the literal EICAR string** ("script contains malicious content"). Build EICAR from char codes so the literal never appears in the script; disable Defender RTP (or path-exclude) too.
|
||||
- After testing: restore Defender RTP (`Set-MpPreference -DisableRealtimeMonitoring $false`) and re-enable Datto AV in the console policy.
|
||||
|
||||
Skill: [[reference_syncro_rmm_api_gui_only]] is the analogous "management is GUI/console-only" constraint. See `.claude/skills/datto-edr/`.
|
||||
@@ -1,12 +1,24 @@
|
||||
---
|
||||
name: reference_guru5070_rust_toolchain
|
||||
description: GURU-5070 has the full local Rust toolchain (cargo + MSVC + protoc) — build/clippy/test the guru-connect workspace LOCALLY instead of the build host; set PROTOC first
|
||||
description: GURU-5070 has a full Rust toolchain installed BUT a WDAC/Smart App Control policy now blocks running rustc/cargo locally (os error 4551) — build the Windows agent on Beast, not here; toolchain paths + CI gates still valid for reference
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed, so GuruConnect can be
|
||||
built/linted/tested locally — **no more build-host (172.16.3.30) round-trips just for `cargo fmt`/clippy.**
|
||||
**BLOCKED AS OF 2026-07-04 — do NOT rely on local Rust builds on GURU-5070.** A Windows
|
||||
Application Control (WDAC / Smart App Control) policy now blocks execution of `rustc.exe`/`cargo.exe`
|
||||
(and `sops.exe`, `openssl`, `gawk`, Python `cryptography`'s native DLL) — attempts fail with
|
||||
`An Application Control policy has blocked this file. (os error 4551)`. This is the same policy behind
|
||||
the "Windows can't confirm who published …" blocks on unsigned binaries. Until the policy is adjusted
|
||||
to trust the toolchain, **compile Windows agent/GuruConnect changes on Beast** (`guru@100.101.122.4`,
|
||||
Tailscale; sccache at `C:\sccache`) via a throwaway `git worktree` + `cargo check` — see
|
||||
[[gururmm-beast-windows-build-host]]. The paths/gates below remain accurate for reference and for when
|
||||
the policy is relaxed. WDAC impact also breaks the `vault`/`sops` path here (use `age`+Node fallback per
|
||||
[[feedback_vault_gcm_shadow_auth]]) and silently swallowed error logging (`CLAUDETOOLS_ROOT=D:/claudetools`
|
||||
wrong-case → `log-skill-error.sh` can't write; real repo is `D:/ClaudeTools`).
|
||||
|
||||
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed (able to build locally *until the
|
||||
2026-07-04 WDAC block above*):
|
||||
|
||||
- **cargo/rustc/clippy/rustfmt:** `C:\Users\guru\.cargo\bin\` (rustup; cargo 1.96, rustfmt 1.9, clippy 0.1.96).
|
||||
- **MSVC C++ Build Tools:** VS2022 BuildTools (VCTools workload) — provides the `x86_64-pc-windows-msvc` linker.
|
||||
|
||||
20
.claude/memory/reference_gururmm_command_timeout_seconds.md
Normal file
20
.claude/memory/reference_gururmm_command_timeout_seconds.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: gururmm-command-timeout-seconds
|
||||
description: GuruRMM agent command dispatch honors timeout_seconds, NOT timeout — long jobs die at ~300s otherwise
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
When dispatching a command to a GuruRMM agent (`POST {RMM}/api/agents/{id}/command`), the
|
||||
execution timeout is controlled by **`timeout_seconds`**, not `timeout`. Passing only
|
||||
`timeout` leaves the agent at its default cap (~300s): long-running commands get killed and
|
||||
often surface as a zombie `status: running` with empty stdout that never terminates.
|
||||
|
||||
For multi-minute/multi-hour work (large uploads, big enumerations) set `timeout_seconds` high
|
||||
(e.g. 10800) — send both fields to be safe. Poll `GET {RMM}/api/commands/{command_id}` for
|
||||
`status` in {completed, failed, cancelled}; cancel a stuck one with
|
||||
`POST {RMM}/api/commands/{id}/cancel`.
|
||||
|
||||
Cost the fleet a full day of failed Birth Biologic SharePoint uploads (Mac session) before
|
||||
this was spotted. See [[reference_sharepoint_graph_large_file_upload]]. Auth helper:
|
||||
`.claude/scripts/rmm-auth.sh` (internal `172.16.3.30:3001`).
|
||||
25
.claude/memory/reference_gururmm_user_manager.md
Normal file
25
.claude/memory/reference_gururmm_user_manager.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: reference_gururmm_user_manager
|
||||
description: GuruRMM has a built-in per-agent User Manager (endpoint local/domain/AAD users) — use it, not raw PowerShell
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
GuruRMM has a **built-in per-agent User Manager tab** (Users + Groups) for managing the
|
||||
*endpoint's* Windows accounts — do NOT hand-roll `Set-ADAccountPassword`/`net user` via `/rmm`
|
||||
PowerShell for routine user ops.
|
||||
|
||||
- UI: agent detail → **User Manager** tab (`dashboard/src/components/UserManagerTab.tsx`).
|
||||
- API: `GET /api/agents/{id}/users` (inventory: users, groups, `domain_join_type`, `domain_name`,
|
||||
`m365_tenant_id`, **`is_dc`**, `last_collected`), `POST /api/agents/{id}/users/refresh`,
|
||||
`POST /api/agents/{id}/users/action` body `{username, action, value?, new_password?, group_name?}`.
|
||||
- Actions: `reset_password`, `set_enabled` (enable/disable), `set_password_never_expires`,
|
||||
`add_to_group`, `remove_from_group`.
|
||||
- **Scope gate:** local accounts always manageable; **domain accounts manageable only when the agent
|
||||
IS a DC** (`is_dc` true) — on a non-DC member they show "Managed in AD" (read-only). AAD accounts
|
||||
show "Managed in Azure" (read-only). So to reset a domain user, target a DC agent.
|
||||
- Advantage over raw PowerShell: structured action keeps the password out of the RMM command
|
||||
history (raw `Set-ADAccountPassword` leaks the plaintext into `command_text`).
|
||||
|
||||
(Distinct from SPEC-027 / `usersApi` = GuruRMM's OWN dashboard login accounts — different thing.)
|
||||
Correction logged 2026-06-29 after I wrongly claimed no built-in action existed. See [[reference_resource_map]].
|
||||
14
.claude/memory/reference_inky_outbound_breaks_dmarc.md
Normal file
14
.claude/memory/reference_inky_outbound_breaks_dmarc.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: reference-inky-outbound-breaks-dmarc
|
||||
description: INKY/GuruProtect outbound re-injection breaks DMARC alignment; reverse-resolve DMARC report source IPs before attributing failures
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
When analyzing DMARC aggregate (rua) reports, **reverse-resolve every failing source IP before attributing a failure to a sender.** Multiple ACG client failures (glaztech.com p=reject, cryoweave.com p=quarantine) traced not to the apps/websites first assumed, but to **INKY (GuruProtect)** outbound: PTRs `ipw-outbound.inkyphishfence.com` and `us.cloud-sec-av.com` (AWS IP pools 3.x / 34.210.x / 35.174.x / 100.2x.x).
|
||||
|
||||
**Mechanism:** INKY receives an already-signed message (M365 `selector1` or cPanel `default` DKIM), **modifies the body** (GuruProtect banner / link rewrite), then re-sends from its own IPs. Body change breaks the original DKIM; INKY IPs aren't in the domain's SPF → SPF fails too → DMARC fails. So a domain can be 99% aligned and still show a steady trickle of INKY-origin fails.
|
||||
|
||||
**INKY topology (per Mike, 2026-06-23):** INKY is installed *directly into M365* via connectors + transport rules per enrolled tenant — NOT an external-only relay. So check tenant enrollment. NOTE: cryoweave M365 is **NOT** enrolled in INKY, yet cryoweave.com mail still appeared from INKY outbound IPs — that traffic is the **IX/cPanel website** (WordPress, `envelope=ix.azcomputerguru.com`, cPanel `default` DKIM) whose *hosting-level* outbound routes through INKY, independent of the M365 tenant. Don't assume "INKY fail" == "tenant on INKY."
|
||||
|
||||
**Fix is INKY-side, not DNS-blind:** republishing cPanel DKIM or adding the web server to SPF will NOT fix it (INKY breaks the sig downstream and the mail comes from INKY's IP, not the web server's). Real options: enable INKY outbound DKIM signing per domain, add INKY's SPF include, or ARC — or route low-volume app/website mail through an authenticated M365 SMTP path so it aligns directly. See [[feedback-dmarc-rua-inky-onboarded-only]].
|
||||
12
.claude/memory/reference_investigator_exo_manageasapp_gap.md
Normal file
12
.claude/memory/reference_investigator_exo_manageasapp_gap.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: reference_investigator_exo_manageasapp_gap
|
||||
description: Why the remediation-tool investigator-exo tier 401s on EXO adminapi; use exchange-op instead
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The **Security Investigator** app (`bfbc12a4-f0dd-4e12-b06d-997e7271e10c`) registration grants only the `full_access_as_app` (EWS) Office 365 Exchange Online app role — it is **missing `Exchange.ManageAsApp`**. The EXO REST admin API (`outlook.office365.com/adminapi/beta/{tid}/InvokeCommand` — Get-Mailbox, Get-InboxRule, Search-UnifiedAuditLog, etc.) requires `Exchange.ManageAsApp`, so the `investigator-exo` token returns **401** on every cmdlet.
|
||||
|
||||
The Exchange Administrator **directory role** is NOT the cause — it is already assigned to the Investigator SP. The gap is the **API permission** on the app registration, which is a manual Entra portal change (suite rule: app registrations stay manual) and arguably shouldn't be added to the read-only Investigator at all.
|
||||
|
||||
**How to apply:** for any EXO `InvokeCommand` work (mailbox reads, inbox rules, message trace, TABL, audit), use the **`exchange-op`** tier — the **Exchange Operator** app (`b43e7342-5b4b-492f-890f-bb5a4f7f40e9`) carries BOTH `full_access_as_app` and `Exchange.ManageAsApp`. Don't waste a round trip on `investigator-exo` for adminapi. See [[reference_tedards_tenant_facts]].
|
||||
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal file
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: reference_msp360_backup_monitoring
|
||||
description: MSP360 MBS /api/Monitoring is the authoritative "is this client backing up" source for the whole ACG fleet
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
For any "is client X actually backing up / when did it last run" question, the authoritative
|
||||
source is the **MSP360 Managed Backup Service API**, not B2 bucket contents (buckets are just
|
||||
where Cloud plans land; a bucket with files does not prove a recent backup ran).
|
||||
|
||||
- Creds: vault `msp-tools/msp360-api.sops.yaml` (API login+password, generated in MSP360
|
||||
console Settings>General — not the console login).
|
||||
- Auth: `POST https://api.mspbackups.com/api/Provider/Login` `{UserName, Password}` -> Bearer
|
||||
`access_token`.
|
||||
- `GET /api/Monitoring` -> every backup plan across all companies: `CompanyName`,
|
||||
`ComputerName`, `PlanName`, `StorageType`, `LastStart`, `Status` (0/1=Success, 2=Warning,
|
||||
3=Failed, 4=Running, 6=Interrupted, 7=completed-with-warnings), `DataCopied`, `TotalData`,
|
||||
`ErrorMessage`. Also `GET /api/Companies` (37, with Destinations) and `GET /api/Users` (59).
|
||||
`/api/Computers` is 404.
|
||||
- Used 2026-07-05 for the GPS backup map (`projects/gps-rmm-audit/backup-map.md`): resolved 11
|
||||
of 12 "no destination found" clients, surfaced 4 not-backing-up findings (AMT + T&C Sorensen
|
||||
= 0 plans, Grabb GND-SERVER failing, Bill Tedards ~12mo stale). MSP360 CompanyName also IDs
|
||||
B2 buckets: `ACG-PST`=Peaceful Spirit, `ACG-TCA`=Tucson Coin and Autograph.
|
||||
- Backup targets are a per-client decision — report mismatches, don't deploy jobs. See
|
||||
[[feedback_backup_targets_never_guessed]]. Related: [[reference_backblaze_storage_rate]].
|
||||
- **Use the `msp360` skill** (`.claude/skills/msp360/`) — built 2026-07-05, full (not
|
||||
read-only): reads (`status`/`monitoring --failed|--company|--stale`/`companies`/`users`/
|
||||
`licenses`/`billing`) plus `--confirm`-gated writes (create/delete user+company,
|
||||
grant/release/revoke license) and a `raw` passthrough for the rest of the MBS API.
|
||||
27
.claude/memory/reference_packetdial_oit_netsapiens.md
Normal file
27
.claude/memory/reference_packetdial_oit_netsapiens.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: reference_packetdial_oit_netsapiens
|
||||
description: VoIP vendor stack — PacketDial = ACG's VoIP department/brand; runs on NetSapiens (the PBX platform) supplied by OIT/OITVOIP (white-label wholesaler). YMCS = Yealink device mgmt.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
ACG's VoIP vendor stack, top to bottom (don't conflate these):
|
||||
- **PacketDial** = ACG's own **VoIP department / customer-facing brand**. `pbx.packetdial.com` is
|
||||
the ACG-branded host. The `packetdial` skill is ACG's VoIP-management tool — keep it named
|
||||
`packetdial` (ACG's brand) even though it speaks the NetSapiens API.
|
||||
- **NetSapiens** = the underlying **PBX/UCaaS software platform** (open API v2, v44.4.10). What the
|
||||
skill actually talks to.
|
||||
- **OIT / OITVOIP** = the **white-label wholesale provider** that runs NetSapiens and resells it to
|
||||
MSPs; ACG buys VoIP wholesale from OIT. `api.ucaasnetwork.com` is the OIT/NetSapiens host (same
|
||||
platform as `pbx.packetdial.com`, different white-label hostname). Reseller territory
|
||||
`91912.service` is ACG's account on OIT's platform; the reseller API key is vaulted at
|
||||
`msp-tools/oitvoip.sops.yaml` (key-id `nsr_hSGUB5Wo`).
|
||||
- **YMCS (Yealink Management Cloud Service)** = separate Yealink **device-management** cloud for the
|
||||
physical phones (`us-api.ymcs.yealink.com`); pairs with the PBX (phones register to
|
||||
pbx.packetdial.com). API creds `services/yealink-ymcs.sops.yaml`; portal login
|
||||
`infrastructure/voip-phones.sops.yaml`.
|
||||
|
||||
One line: customer-facing brand = **PacketDial (ACG)**; platform = **NetSapiens**; wholesaler =
|
||||
**OIT/OITVOIP**; phone device-mgmt = **YMCS (Yealink)**.
|
||||
|
||||
Related: [[reference_resource_map]]
|
||||
32
.claude/memory/reference_remediation_tool_365_access.md
Normal file
32
.claude/memory/reference_remediation_tool_365_access.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: reference_remediation_tool_365_access
|
||||
description: The remediation-tool app suite has full M365 access (incl. SharePoint via cert); don't declare "no access" on an accessDenied
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The ComputerGuru remediation-tool apps collectively have **broad, working access across ALL of
|
||||
M365** — Graph, Exchange Online, Defender, AND SharePoint Online. When a call fails it's almost
|
||||
always wrong-tier / wrong-endpoint / not-consented / the SharePoint cert gotcha — **not** a real
|
||||
lack of access. Do NOT tell the user "the tool can't do X" without checking the live permission
|
||||
map first (decode the token `roles` claim).
|
||||
|
||||
Key facts:
|
||||
- **SharePoint app-only requires a CERTIFICATE.** A `client_secret` token is rejected on every
|
||||
SharePoint endpoint (REST `/_api` and CSOM `/_vti_bin/client.svc/ProcessQuery`) with
|
||||
`"Unsupported app only token"`. The Tenant Admin app has a cert in the vault and holds
|
||||
SharePoint-resource `Sites.FullControl.All`.
|
||||
- `get-token.sh` now has **`sharepoint`** (content) and **`sharepoint-admin`** (tenant admin)
|
||||
tiers — cert-forced, tenant resource auto-resolved from Graph `/sites/root`
|
||||
(override `SP_RESOURCE_ENV`). Added 2026-07-01.
|
||||
- Graph `GET /admin/sharepoint/settings` needs `SharePointTenantSettings.Read.All`, which NO app
|
||||
holds → that route 403s. Read/write SharePoint tenant settings via the **CSOM admin API**
|
||||
(`sharepoint-admin` tier) instead. Tenant settings live on the Tenant object
|
||||
(TypeId `{268004ae-ef6b-4e9b-8425-127220d84719}`) — e.g. `SelfServiceSiteCreationDisabled`.
|
||||
- Restricting employee SharePoint site creation = `SelfServiceSiteCreationDisabled=true` (CSOM)
|
||||
AND restrict M365 Group creation (Entra `Group.Unified` directory setting via `user-manager`);
|
||||
neither affects edit rights on existing sites.
|
||||
|
||||
Full detail (live per-tier permission map + CSOM examples):
|
||||
`.claude/skills/remediation-tool/references/app-permissions-and-sharepoint.md`. Surfaced by
|
||||
Syncro #32492 (Birth Biologic). See also [[feedback_syncro_billing]].
|
||||
@@ -129,7 +129,7 @@ type: reference
|
||||
- Detail: [[reference_gururmm]], [[project_gururmm]], [[feedback_gururmm]].
|
||||
|
||||
### ScreenConnect / CW Control
|
||||
- Primary remote-access tool. `msp-tools/screenconnect.sops.yaml`.
|
||||
- Primary remote-access tool. `msp-tools/screenconnect.sops.yaml`. Skill: `/screenconnect` (sessions, parameterized self-tagging installer, gated backstage control). See [[reference_screenconnect_api]].
|
||||
- **Gotcha:** Toolbox scripts truncate lines >80 chars silently; no inline comments mid-script. See [[reference_msp_audit_scripts]].
|
||||
|
||||
### Splashtop (SOS / Streamer)
|
||||
|
||||
30
.claude/memory/reference_rmm_deploy_via_screenconnect.md
Normal file
30
.claude/memory/reference_rmm_deploy_via_screenconnect.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: reference_rmm_deploy_via_screenconnect
|
||||
description: Best channel to mass-deploy the GuruRMM agent to client workstations = ScreenConnect send-command (not DC remote-exec)
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**To push the GuruRMM agent onto a client's existing machines, the reliable channel is
|
||||
ScreenConnect `send-command` (Backstage), NOT remote-exec from the domain controller.**
|
||||
|
||||
Discovered 2026-07-03 deploying to Instrumental Music Center (see
|
||||
`projects/gps-rmm-audit/tracker.md`). Remote-exec FROM the DC to Win10/11 workstations
|
||||
fails on default client settings:
|
||||
- WMI/`Win32_Process` over **DCOM** -> "RPC server unavailable" (DCOM firewalled on clients)
|
||||
- `schtasks /S` -> connects over SMB but Win11 **rejects the task definition** from an older
|
||||
(Server 2016) DC ("The request is not supported")
|
||||
- `New-PSDrive`/`sc.exe` admin-share from SYSTEM context -> access/parse failures
|
||||
- WinRM is off by default on workstations
|
||||
|
||||
**What works cleanly:** ScreenConnect. ACG endpoints already run the SC agent, and
|
||||
`send-command` runs on the guest **as SYSTEM** — no credentials, no firewall fight, no
|
||||
DA-password-in-logs concern. Pattern (via the `[[screenconnect]]` skill):
|
||||
1. `GetSessionsByName` with the EXACT hostname -> sessionID (no list-all method; must query by exact name).
|
||||
2. Build the site installer one-liner: `irm 'https://rmm.azcomputerguru.com/install/<SITE-CODE>/windows'|iex` (site code from the client's vault `gururmm-site-*.sops.yaml`).
|
||||
3. Encode it: `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand <base64-UTF16LE>` (avoids all quoting; no /TR length limit like schtasks).
|
||||
4. `send-command --session <id> --command "<that>" --confirm`. Online guests install + enroll in ~1-3 min; offline guests QUEUE in SC and install on reconnect.
|
||||
5. Verify via GuruRMM `/api/agents` (hostname appears under the client, status online).
|
||||
|
||||
Note `iconv` is absent in this Git-Bash — compute the base64 with `py -c "import base64; ..."`.
|
||||
Related: `[[project_av_migration_bitdefender_to_edr]]` (same channel can push the Datto EDR agent + remove Bitdefender once RMM is on).
|
||||
34
.claude/memory/reference_rmm_drive_map_explorer_refresh.md
Normal file
34
.claude/memory/reference_rmm_drive_map_explorer_refresh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: reference_rmm_drive_map_explorer_refresh
|
||||
description: Mapping a drive for a user via RMM user_session works but their running Explorer won't show it until a shell DRIVEADD notify; also UNC \\ gets eaten in heredoc+jq dispatch
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
When you map a persistent network drive **for a logged-on user** via the GuruRMM agent's
|
||||
`context: user_session` (`net use` / `New-SmbMapping -Persistent $true`), two things bite:
|
||||
|
||||
1. **The map lands in the user's session but their already-running Explorer won't display it.**
|
||||
The drive IS mounted (verify: `user_session` SID == `explorer.exe` SID via
|
||||
`Get-CimInstance Win32_Process -Filter "Name='explorer.exe'"`) and `Test-Path X:\` is True,
|
||||
but "This PC" doesn't show the icon because the shell never got the add notification.
|
||||
**Fix (no disruption, runs in user_session = the user's session 1):**
|
||||
```powershell
|
||||
$sig = @'
|
||||
[DllImport("shell32.dll", CharSet=CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, string item2);
|
||||
'@
|
||||
$sh = Add-Type -MemberDefinition $sig -Name ShellNotify -Namespace W -PassThru
|
||||
$sh::SHChangeNotify(0x00000100, 0x0005, 'X:' + [char]92, $null) # SHCNE_DRIVEADD, SHCNF_PATHW
|
||||
```
|
||||
The persistent map (`HKCU\Network\X`) auto-reconnects + shows on the user's NEXT logon anyway,
|
||||
so this is only to surface it in the current session. Restarting explorer.exe also works but
|
||||
closes the user's open windows. An interactive scheduled task (`LogonType Interactive`) to
|
||||
"remap in the session" returned `LastTaskResult=2` and did NOT help — use SHChangeNotify.
|
||||
|
||||
2. **UNC double-backslashes get mangled to single in the heredoc -> jq -> agent -> PowerShell chain.**
|
||||
`\\cs-server\share` arrives as `\cs-server\share` -> "error 67 / network name not found" or net-use
|
||||
hangs (looks like a missing/broken share). Single-backslash local paths (`D:\Shares`) are fine.
|
||||
**Fix:** build the UNC at runtime from `[char]92` so no literal `\\` traverses the dispatch:
|
||||
`$bs=[char]92; $unc = "{0}{0}server{0}share" -f $bs`. See [[feedback_windows_quote_stripping]].
|
||||
|
||||
Proven 2026-06-24 on Cascades #32193 (Executive share, E: for Ashley.Jensen + Meredith.Kuhn).
|
||||
19
.claude/memory/reference_rmm_map_network_drive.md
Normal file
19
.claude/memory/reference_rmm_map_network_drive.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: reference_rmm_map_network_drive
|
||||
description: How to push a persistent mapped network drive to a machine via GuruRMM when net use fails with error 67 (double-hop)
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Pushing a **persistent mapped drive** to an endpoint via the GuruRMM agent (`/rmm`) fails when the target share is on a *remote* server:
|
||||
|
||||
- Running `net use` in `context: user_session` impersonates the logged-on user, but that WTS-impersonated token has **no network credential** to make the second hop to the file server. Result: `System error 67 (network name cannot be found)` on `net use` and `System error 1702 (binding handle is invalid)` on `net view` — even with explicit `/user:.. <pw>`. This is the "SMB error 67 = RMM artifact" documented in `wiki/clients/cascades-tucson.md` (server + share are healthy; access works in a real interactive session).
|
||||
|
||||
**Reliable workaround — plant the map so it mounts at the user's next real logon:**
|
||||
1. `cmdkey /add:<SERVER> /user:<DOMAIN\user> /pass:<pw>` in `user_session` — this is a *local* write to the user's Credential Manager and DOES succeed.
|
||||
2. Write the persistent-map registry keys into the user's hive `HKCU:\Network\<DriveLetter>`: `RemotePath` (REG_SZ, `\\SERVER\Share`), `UserName` (REG_SZ, `DOMAIN\user`), `ProviderName` (`Microsoft Windows Network`), `ProviderType` (DWord `131072`), `ConnectionType` (DWord `1`), `DeferFlags` (DWord `4`).
|
||||
3. At the user's **next interactive logon / reboot**, Windows reconnects the drive silently using the cmdkey credential. It will NOT appear in an already-open session — for immediate visibility, run `net use <D>: "\\SERVER\Share"` in the *live* interactive session (ScreenConnect), not through the RMM agent.
|
||||
|
||||
Non-domain-joined (workgroup) endpoints authenticate with `DOMAIN\user` + password saved via cmdkey — the domain account only needs to exist and be reachable, the client PC does not need to be joined.
|
||||
|
||||
PowerShell-in-RMM gotcha hit while doing this: a double-quoted string ending in a backslash (`"W:\"`, `"W:\\"`) breaks the parser — use bare path tokens (`Test-Path W:\`) or single quotes. See [[feedback_windows_quote_stripping]].
|
||||
33
.claude/memory/reference_rmm_spawn_headless_claude.md
Normal file
33
.claude/memory/reference_rmm_spawn_headless_claude.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: rmm-spawn-headless-claude
|
||||
description: Spawn a headless `claude -p` on any RMM-managed Windows box that has Claude Code installed — reaches isolated sites (AD2) the coord API can't
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Any RMM-managed Windows endpoint with Claude Code installed can run an autonomous headless
|
||||
Claude, dispatched via a GuruRMM command — even a site that's isolated from the ACG coord API.
|
||||
The RMM agent phones home outbound, so this works where [[ad2-comms-via-sync-only]] says coord
|
||||
can't reach (coord `:8001` blocked ≠ RMM `:3001` blocked). Validated 2026-07-01 on AD2
|
||||
(Dataforth DC, agent `cfa93bb6-...`, claude v2.1.181 at `C:\Users\sysadmin\.local\bin\claude.exe`).
|
||||
|
||||
Recipe:
|
||||
- Dispatch with **`"context":"user_session"`** — needs an interactive logged-on user (check
|
||||
`quser`); an admin session comes back elevated. `claude` is a per-user install, not on the
|
||||
SYSTEM PATH, so SYSTEM context won't find it.
|
||||
- **GOTCHA: unset `ANTHROPIC_API_KEY` first.** A stale machine-level `ANTHROPIC_API_KEY` (108-char)
|
||||
shadows the good OAuth creds and makes `claude -p` fail with `Invalid API key · Fix external API
|
||||
key`. `Remove-Item Env:\ANTHROPIC_API_KEY` (+ `$env:ANTHROPIC_API_KEY=$null`) before invoking →
|
||||
falls back to `~\.claude\.credentials.json` OAuth and authenticates.
|
||||
- **Detach + poll.** A real audit run takes many minutes; RMM caps command lifetime (see
|
||||
[[gururmm-command-timeout-seconds]] — use `timeout_seconds`). Launch detached
|
||||
(`Start-Process powershell -File runner.ps1 -WindowStyle Hidden`), have the runner write the
|
||||
deliverable to a file + a `DONE.txt` marker, and poll the marker via short RMM commands.
|
||||
- Run headless as: `claude -p <brief> --permission-mode bypassPermissions --output-format text`.
|
||||
For an audit, give an ironclad READ-ONLY brief (no writes/git/state changes) since
|
||||
bypassPermissions lets it run any tool. Pass the brief via a base64'd file to dodge quoting.
|
||||
- Windows/Git-Bash: the mingw `curl` intermittently hits `Permission denied` (AV lock) —
|
||||
use `/c/Windows/System32/curl.exe` for the dispatch. See [[feedback_windows_quote_stripping]].
|
||||
|
||||
Use for: live audits/data-gathering on isolated or hard-to-reach managed boxes without the async
|
||||
sync-handoff. Keep it read-only on production (AD2 is a domain controller).
|
||||
@@ -1,15 +1,22 @@
|
||||
---
|
||||
name: reference_screenconnect_api
|
||||
description: Working auth + method for the ACG ScreenConnect RESTful API extension (CTRLAuthHeader = raw secret, GetSessionsByName)
|
||||
description: ACG ScreenConnect RESTful API auth + verified method surface (CTRLAuthHeader=raw secret); now wrapped by the /screenconnect skill
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
ACG ScreenConnect RESTful API extension — verified working call (2026-06-02, Howard). Credentials in vault `msp-tools/screenconnect.sops.yaml` (`credentials.username`, `credentials.api_secret`).
|
||||
ACG ScreenConnect (CW Control) RESTful API Manager extension. Auth verified 2026-06-02;
|
||||
full method surface + parameterized-installer deploy verified live 2026-06-21 (Howard).
|
||||
**Now wrapped by the `/screenconnect` skill** (`.claude/skills/screenconnect/`) — use that
|
||||
(`sc.py`/`sc_client.py`) rather than hand-rolling calls. Secret in vault
|
||||
`msp-tools/screenconnect.sops.yaml` (`credentials.api_secret`).
|
||||
|
||||
- **Host:** `https://computerguru.screenconnect.com` **extension-guid:** `2d558935-686a-4bd0-9991-07539f5fe749`
|
||||
- **Auth (the non-obvious part):** header `CTRLAuthHeader: <raw api_secret>` with **NO `Basic ` prefix and no base64** + header `Origin: https://computerguru.screenconnect.com`. Putting the secret in `Authorization: Basic <b64>`, or `CTRLAuthHeader: Basic <b64>`, both return 401. Raw secret in CTRLAuthHeader is what works.
|
||||
- **Only method that exists:** `POST /App_Extensions/<guid>/Service.ashx/GetSessionsByName` with JSON body `{"sessionName":"<name>"}`. Every other `Get*` name (GetSessions, GetSessionList, GetHosts, ...) returns 500 `"Web method does not exist"`. Bad/missing params return 500 `"Unknown parameter: <x>"` — the valid param is `sessionName`.
|
||||
- **Big limitation:** the match is on the session `Name` field, which is **blank for unattended access agents**, so this api user only enumerates a handful of named sessions — it CANNOT list a client's full machine inventory. For per-machine last-seen across a whole client, the API is not sufficient; read the ScreenConnect console (or a screen recording) instead. Session objects do carry `LastConnectedEventTime`, `LastEventTime`, `GuestInfo.LastActivityTime`, and custom props CP1=Company / CP2=Site / CP3=Tag.
|
||||
- **Auth (the non-obvious part):** header `CTRLAuthHeader: <raw api_secret>` with **NO `Basic ` prefix and no base64** + header `Origin: https://computerguru.screenconnect.com`. `Authorization: Basic <b64>` or `CTRLAuthHeader: Basic <b64>` both 401. Raw secret in CTRLAuthHeader is what works. Endpoint: `POST /App_Extensions/<guid>/Service.ashx/<Method>`.
|
||||
- **Verified methods (CORRECTS the old "only GetSessionsByName" note):** reads take a JSON object, writes take a POSITIONAL ARRAY.
|
||||
- Reads: `GetSessionsByName {"sessionName":"<name>"}`, `GetSessionDetailsBySessionID {"sessionID":"<id>"}`, `GetSessionBySessionID`.
|
||||
- Writes (gated in the skill): `SendCommandToSession ["<id>","<cmd>"]` (backstage command on the guest), `SendMessageToSession ["<id>","<msg>"]`, `UpdateSessionCustomProperties ["<id>",["cp1","cp2","cp3",...]]`. CP1=Company / CP2=Site / CP3=Tag (up to CP8).
|
||||
- **Parameterized access installer (deploy):** the cloud serves a pre-keyed installer at `/Bin/ScreenConnect.ClientSetup.<ext>?e=Access&y=Guest&t=<name>&c=<CP1>&c=<CP2>&c=<CP3>...` (ext: msi/exe/pkg/deb/rpm/sh). The repeated `c=` self-tag the agent on install, so an RMM-pushed install self-places into the right Company/Site/Tag. Windows silent: `msiexec /i <file> /qn /norestart`. VERIFIED end-to-end on RMM-TEST-MACHINE 2026-06-21.
|
||||
- **Real limitation (still true):** NO full-fleet inventory method — `GetSessions`/`GetAllSessions`/`GetSessionGroups` return 500 `"Web method does not exist"`. You CANNOT list a client's whole machine inventory via this API yet; needs Mike to update the RESTful API Manager extension (coord msg 60d9e876). Workaround: the installer sets session Name = machine name, so by-name lookup works post-install.
|
||||
|
||||
Used during the Dataforth Syncro asset cleanup as the third liveness source alongside Syncro + Bitdefender. See [[reference_acg_msp_stack]].
|
||||
Used during the Dataforth Syncro asset cleanup as a liveness source. See [[reference_acg_msp_stack]] and the `/screenconnect` skill SKILL.md.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: reference_screenconnect_custom_property_slots
|
||||
description: ACG ScreenConnect custom-property slot -> label mapping (CP1..CP8) for session tagging
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
ACG's ConnectWise ScreenConnect instance (`computerguru.screenconnect.com`) custom-property
|
||||
slot order, reverse-engineered 2026-07-03 from a labeled test session (HOWARD-HOME, values
|
||||
"howard company / howards site / Howard department / howard type / ... / howard tag"):
|
||||
|
||||
- **CP1** (index 0) = **Company**
|
||||
- **CP2** (index 1) = **Site**
|
||||
- **CP3** (index 2) = **Department**
|
||||
- **CP4** (index 3) = **Device Type**
|
||||
- CP5 (index 4) = unused/other
|
||||
- CP6 (index 5) = unused/other
|
||||
- CP7 (index 6) = unused/other
|
||||
- **CP8** (index 7) = **Tag**
|
||||
|
||||
The API does NOT expose the property LABELS (only values), so this mapping is the reference.
|
||||
`UpdateSessionCustomProperties [sessionID, [cp1..cp8]]` REPLACES the whole 8-element array — always
|
||||
read the session's current values first, modify only the slots you intend, write the full array back.
|
||||
Set via the `[[screenconnect]]` skill: `sc.py set-properties --session <id> --props-json '[...8...]' --confirm`.
|
||||
Read via `GetSessionsByName` -> `.CustomPropertyValues`.
|
||||
|
||||
Used by the GPS->RMM audit ScreenConnect cleanup (`projects/gps-rmm-audit/`): normalize Company,
|
||||
set Site/Department/Device Type/Tag per machine, dedupe sessions.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: sharepoint-graph-large-file-upload
|
||||
description: Uploading files to SharePoint via Graph — simple PUT <4MB, chunked upload session >=4MB; verify counts via delta
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Pushing a folder tree into a SharePoint doc library via Microsoft Graph (app-only):
|
||||
|
||||
- **<4MB:** simple `PUT /drives/{drive}/root:/{path}:/content`.
|
||||
- **>=4MB:** MUST use an **upload session** — `POST .../root:/{path}:/createUploadSession`
|
||||
then `PUT` the file in chunks (multiple of 320 KiB; 10 MB works) with a
|
||||
`Content-Range: bytes {start}-{end}/{total}` header. In PowerShell 5.1
|
||||
`Invoke-RestMethod -Headers @{ 'Content-Range'=... }` handles this fine. A naive script that
|
||||
only does <4MB PUTs will silently skip every large file and never reach the target count.
|
||||
- **Long Windows paths (>260):** prefix the local path with `\\?\` for `[IO.File]` reads.
|
||||
- **Idempotent sync:** existence-check each file (`GET root:/{path}?$select=size`) and skip if
|
||||
size matches — this also catches/repairs partial-upload residue from earlier failed runs.
|
||||
- **Throughput:** a single sequential upload stream to SharePoint Online plateaus ~40 Mbps
|
||||
regardless of link speed (per-session SPO throttle + PS5.1 HTTP stack + Expect100Continue).
|
||||
For speed use parallel file streams + larger chunks + `Expect100Continue=$false`.
|
||||
- **Verify total file count** with the Graph **delta** endpoint
|
||||
(`/drives/{drive}/root/delta`) — whole-drive enumeration in a few paged calls, far cheaper
|
||||
than recursive `/children`.
|
||||
|
||||
Proven end-to-end on Birth Biologic Quality Systems (3,768 files, 301 >=4MB, ~29.7 GB;
|
||||
largest 3.94 GB). Dispatched via GuruRMM — see [[gururmm-command-timeout-seconds]].
|
||||
32
.claude/memory/reference_syncro_agent_handle_leak.md
Normal file
32
.claude/memory/reference_syncro_agent_handle_leak.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: reference_syncro_agent_handle_leak
|
||||
description: RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Symptom chain that hid the real cause (IMC1 / Instrumental Music Center, 2026-06-25):**
|
||||
RemoteApp/RDP launch fails → *"There are no available computers in the pool"* (RDP **error 0x3 / extended 0x408**).
|
||||
The RD Connection Broker **Admin** log (`Microsoft-Windows-TerminalServices-SessionBroker/Admin`,
|
||||
**Event 802**) shows the truth: *"RD Connection Broker failed to process the connection request …
|
||||
Error: **Insufficient system resources** exist to complete the requested service."* The
|
||||
SessionBroker-Client log shows 1296 "Element not found" / 1306 redirect-failed. The collection +
|
||||
session host are healthy (`NewConnectionAllowed: Yes`), so the broker isn't the bug — the **box is
|
||||
out of a kernel resource**.
|
||||
|
||||
**Root cause:** the **Syncro RMM agent `SyncroLive.Agent.Runner` was leaking HANDLES** — 1,135,414
|
||||
handles in one process (~80% of the box's 1.41M total). Handle/object exhaustion → broker can't
|
||||
create the session.
|
||||
|
||||
**Diagnose:** `Get-Process | Sort-Object Handles -Descending | Select -First 6 Name,Handles,Id` and
|
||||
`(Get-Process | Measure-Object Handles -Sum).Sum`. Memory looks fine (it's handles, not RAM/commit).
|
||||
Services on the box: `Syncro`, `SyncroLive`, `SyncroOvermind` (SyncroRecovery).
|
||||
|
||||
**Fix (no reboot needed):** `Stop-Process -Name 'SyncroLive.Agent.Runner' -Force` — the Syncro
|
||||
watchdog respawns it clean (dropped 1.41M → 280K handles, runner came back at ~900). Have the user
|
||||
retry the RemoteApp immediately.
|
||||
|
||||
**It recurs** (leak accumulates over uptime) → schedule a periodic SyncroLive restart and/or update the
|
||||
agent; **likely fleet-wide** — sweep other client servers for high-handle `SyncroLive.Agent.Runner`
|
||||
(deferred 2026-06-25). IMC1 also had a separate pending reboot (wedged KB5075999) + expired RDS certs.
|
||||
See [[reference_resource_map]].
|
||||
15
.claude/memory/reference_syncro_rmm_api_gui_only.md
Normal file
15
.claude/memory/reference_syncro_rmm_api_gui_only.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: reference-syncro-rmm-api-gui-only
|
||||
description: Syncro's public API cannot manage RMM policies/folders — creation, assignment, and asset moves are GUI-only (live-verified 2026-06-25)
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Syncro RMM policy management is GUI-only — the public REST API does NOT expose it.** Live-verified against the ACG production tenant (computerguru.syncromsp.com) on 2026-06-25:
|
||||
|
||||
- `GET /customer_assets` objects carry a read-only **`policy_folder_id`** field (which policy folder the machine sits in). **`PUT /customer_assets/{id}` with `policy_folder_id` is silently ignored** — returns HTTP 200 but the value never changes. Proven by a flip-and-restore test on ACG-internal asset 12335235 (DESKTOP-0O8A1RL): value stayed at folder 692253. **You CANNOT move a machine between policy folders via the API.**
|
||||
- `/policies`, `/policy_builders`, `/rmm_policies`, `/asset_policies` all return **404** — no policy-CRUD endpoints exist. Policy Builder (the `/policy_builders` GUI page) is web-console only.
|
||||
- `/policy_folders` (collection and specific-ID) returns **401** — the route exists but our API token lacks RMM/policy scope. A re-issued token *might* read folders, but since assets can't be moved anyway, it's moot for the move use case.
|
||||
- Syncro docs (docs.syncrosecure.com / docs.syncromsp.com "Work with Policies") confirm: policies are created in Policy Builder, assigned via an Organization's "Assets & Policies" subtab "Update Assigned Policy" dropdown, or "Bulk Assign Top-Level Policy" — all GUI, **no API mention**.
|
||||
|
||||
**How to apply:** Do NOT attempt to build `/syncro move-asset` or any Syncro RMM policy/folder/group capability — it's not buildable on the public API. Don't re-probe these endpoints. The only API-drivable policy surface in the fleet is the `bitdefender` skill (GravityZone: create/assign policies, custom groups, move endpoints). For Syncro RMM policy work, direct the user to the Syncro web console. The `/syncro` skill stays PSA-only (tickets/billing/customers/scheduling/estimates + read-only asset lookup). See [[feedback-psa-default-syncro]].
|
||||
27
.claude/memory/reference_tailscale_subnet_key_expiry.md
Normal file
27
.claude/memory/reference_tailscale_subnet_key_expiry.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: reference_tailscale_subnet_key_expiry
|
||||
description: "Internet OK but all of 172.16.3.x dead" = Tailscale infra-node key expiry, not a LAN outage. How to diagnose + the fallback path.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The ACG internal subnet **172.16.3.x is reached over Tailscale**, not a local LAN — `pfsense-2`
|
||||
(the pfSense node) is the **subnet router** advertising **172.16.0.0/22**. Key hosts on it:
|
||||
Gitea/Jupiter `172.16.3.20:3000`, GuruRMM + coord `172.16.3.30:3001`/`:8001`.
|
||||
|
||||
**Symptom → cause:** if `sync.sh` fetch fails and the WHOLE `172.16.3.x` subnet is unreachable
|
||||
(both .20 and .30) **while general internet is fine**, the cause is almost always a **Tailscale
|
||||
node KEY EXPIRY** on an infra node (the subnet router or a server) — an expired key drops that node
|
||||
off the tailnet, killing the route. It is NOT a "transient blip" and NOT a real LAN outage (logged
|
||||
as a correction 2026-06-25 after I mis-called it). Mike **disabled key expiration** on the infra
|
||||
node(s) 2026-06-25 so it shouldn't recur; if it does, re-auth the node + confirm expiry is off in the
|
||||
Tailscale admin console.
|
||||
|
||||
**Diagnose (Windows `tailscale.exe` at `C:\Program Files\Tailscale\`):**
|
||||
- `tailscale status` — look for peers marked `offline`/key-expired, esp. `pfsense-2` and `gururmm-server`.
|
||||
- `tailscale debug prefs | grep RouteAll` — must be `true` (this machine accepts subnet routes).
|
||||
- `tailscale status --json` — confirm a peer advertises `172.16.0.0/22` (PrimaryRoutes) and is `Online`.
|
||||
- `tailscale ping <tailnet-100.x>` — tests tailnet path independent of the subnet route.
|
||||
|
||||
**Fallback:** `gururmm-server` is directly reachable at its **tailnet IP `100.86.12.15:3001`** — usable
|
||||
in place of `172.16.3.30:3001` if the subnet route is down but the node itself is up. See [[feedback_tmp_path_windows]].
|
||||
16
.claude/memory/reference_tedards_tenant_facts.md
Normal file
16
.claude/memory/reference_tedards_tenant_facts.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: reference_tedards_tenant_facts
|
||||
description: Tedards (Bill Tedards law office) M365 tenant facts for investigations
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Tedards** = Bill Tedards law office. M365 tenant `tedards.net`, tenant ID `4fcbb1f4-fbf9-4548-a93e-7d14a3c091e6`. Registry lists Onboarded=NO but the ComputerGuru apps ARE consented and working (Graph investigator + EXO exchange-op both verified live 2026-06-25).
|
||||
|
||||
Mailboxes: `bt@tedards.net` (Bill, owner), `y226@tedards.net` (Yvonne). Bill files mail by legal matter number into deep Inbox subfolders (e.g. "8445 BOLTON [Farmers TX]", "BOLTON, Lindsay"); a top-level "DUPLICATE need to check" folder (~11,864 items) is junk from a **botched mail import years ago** — ignore it.
|
||||
|
||||
Security gaps found 2026-06-25: tenant was **dehydrated** (never customized) — ran `Enable-OrganizationCustomization` (irreversible, one-time) then `Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true` (both HTTP 200). Flag later confirmed `true`, BUT app-only `Search-UnifiedAuditLog` (via exchange-op InvokeCommand) returns **zero** records for this tenant even hours after ingestion — proven against ~11,800 known dedup MoveToDeletedItems events AND a tenant-wide `RecordType=ExchangeItem` query (both 0). Conclusion: **app-only UAL cannot read mailbox-item records here** — do NOT rely on it for mailbox-item attribution; use device-statistics sync-time + EWS bait timing instead (how the bt@ culprit was found). Before enabling, UAL was OFF entirely. bt@ mailbox syncs to **9 devices** (5 aging iOS Mail/EAS, a Mac Outlook, 2 Outlook-for-iOS added 2026-06-25). Per-mailbox AuditEnabled=true but not queryable since Search-MailboxAuditLog is deprecated + UAL ingestion off.
|
||||
|
||||
EXO access: use the `exchange-op` tier, not `investigator-exo` — see [[reference_investigator_exo_manageasapp_gap]]. Ongoing matter: Wirechunk/agencyzoomify.com DMARC + the bt@ "delete folder" deletions (ticket #5070, #32228).
|
||||
|
||||
**bt@ "delete folder" mystery — SOLVED 2026-06-26 (root cause = client-side device auto-delete).** Lindsay's (lindsay@agencyzoomify.com) Bolton-thread mail auto-moves to Deleted Items. Proven via bait test: restored the 3 msgs to Inbox via EWS, all 3 were re-deleted to Deleted Items at the identical instant (02:54:24Z) — automated, not human. Eliminated: inbox rules (incl. hidden), sweep rules, transport rules, forwarding, delegates, folder perms, and any OAuth app with Mail.ReadWrite (none exist; admin apps only have MailboxSettings.ReadWrite/Mail.Send/Exchange.Manage). Only mail-MOVE capability present = Apple native iOS Mail (appId 32f67a9b, EAS+EWS) + Outlook-for-iOS. 5 Apple/Outlook-iOS devices push-synced within 8s of the re-delete; all activity from Bill's single home IP 69.242.239.94 (NOT a compromise). Bisection test (2026-06-26 03:23): removed/disabled Outlook-for-iOS, re-moved msgs to Inbox — re-deleted again in 4s, AND the re-delete (03:23:31Z) fired BEFORE the Outlook-iOS clients re-synced (03:23:38/48) → **Outlook-iOS EXONERATED; culprit = a NATIVE iOS Mail (EAS) device** with on-device "Block Sender → Move to Trash" for lindsay@agencyzoomify.com. Narrowed to the two devices syncing at the delete instant: **iPhone16C2** and **iPad15C8** (Bill's current iPhone + iPad). On-device block list is NOT server-readable — Bill must remove Lindsay from Blocked on those devices (iOS Settings → Mail → Blocked). Note: Set-CASMailbox OutlookMobileEnabled has heavy propagation lag (didn't enforce during the test window); same lag seen on Set-AdminAuditLogConfig. To pin the exact one device, block one EAS DeviceId and re-bait. Removed 2 Outlook-iOS partnerships (they auto-re-add) + toggled OutlookMobileEnabled (reverted to true, queued). **CONFIRMED 2026-06-26 by Yvonne: found Bolton's blocked contact on Bill's NEW iPad (= iPad15C8); unblocked on phone + new iPad, checking other iPads — validates the device-block root cause.**
|
||||
35
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal file
35
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: reference_windows_edition_upgrade_rmm
|
||||
description: Verified fleet procedure to upgrade a Windows machine Home->Pro/Pro-for-Workstations via RMM, with the three gotchas (DISM error 50, shutdown /t drop, PfW MAK auto-upgrade).
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Verified on ACG-Tech03L 2026-07-06 (Home 26200 -> Win 11 Pro for Workstations, permanently activated).
|
||||
|
||||
**Procedure (all steps as SYSTEM via `/rmm`, byte-exact via -EncodedCommand):**
|
||||
1. Probe: EditionID/LicenseStatus (`.rmm-key-probe.ps1` pattern), confirm no logged-in user
|
||||
(`query user`) so a reboot is safe.
|
||||
2. Flip edition: `changepk.exe /ProductKey VK7JG-NPHTM-C97JM-9MPGT-3V66T` (generic Pro key).
|
||||
Flips EditionID Core->Professional **live** (registry) + sets CBS RebootPending. exit 0.
|
||||
3. Reboot to finalize (see gotcha #2).
|
||||
4. Install MAK + activate: `slmgr /ipk <MAK>` then `slmgr /ato`; verify `slmgr /xpr` =
|
||||
"permanently activated" and WMI SoftwareLicensingProduct LicenseStatus=1.
|
||||
|
||||
**Gotcha 1 — DISM is a dead end for online edition change.** `DISM /online /Set-Edition:Professional`
|
||||
returns **Error 50 "Setting an edition is not supported with online images"** even though
|
||||
`/Get-TargetEditions` lists Professional. Use `changepk.exe`, not DISM, for online Home->Pro.
|
||||
|
||||
**Gotcha 2 — `shutdown /r /t N` is silently DROPPED in the SYSTEM service session.** The command
|
||||
returns exit 0 but the box never reboots (verified twice on tech03l). Also `shutdown /c "comment"`
|
||||
fails outright because the RMM->cmd.exe layer strips the quotes ([[feedback_windows_quote_stripping]]).
|
||||
Reboot reliably with `Restart-Computer -Force` (encoded PS) or `shutdown /r /t 0 /f` (no comment).
|
||||
NOTE: on a fast SSD the edition-upgrade reboot can be <30s of downtime — a 30s-interval last_seen
|
||||
monitor can miss the gap entirely; confirm the reboot by `LastBootUpTime` advancing, not by catching
|
||||
the offline window. `is_connected` is null fleet-wide — track `last_seen` age, never that flag.
|
||||
|
||||
**Gotcha 3 — the ACG MAK `infrastructure/windows-pro-mak` is a Pro-for-WORKSTATIONS MAK** (mislabeled
|
||||
"Pro"). `slmgr /ipk` of it on a Professional machine AUTO-UPGRADES the edition to ProfessionalWorkstation
|
||||
(a strict superset of Pro). If a client genuinely needs *plain* Pro, this key will over-shoot to PfW —
|
||||
there is no plain-Pro MAK in the vault. Billing: each activation consumes one MAK count = $99 to the
|
||||
customer (ACG-internal machines: no invoice, still burns a count).
|
||||
28
.claude/memory/windows-offline-dism-repair-gotchas.md
Normal file
28
.claude/memory/windows-offline-dism-repair-gotchas.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: windows-offline-dism-repair-gotchas
|
||||
description: Windows won't-boot / offline DISM repair playbook — diagnose wedged-update boot loops, and the WinPE source/mount gotchas (wim: not allowed offline, Ventoy breaks WIM mount)
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Field-learned gotchas for repairing a Windows box that won't boot, from offline WinPE/recovery. First hit on Four Paws AvImark server (Syncro #32447, 2026-06-23) — boot-looping into Automatic Repair.
|
||||
|
||||
**1. Diagnose the REAL boot blocker before chasing DISM corruption.**
|
||||
A machine that drops into **Automatic Repair** is failing on something boot-critical (disk, registry hive, boot files, or a wedged update) — NOT on shell/Search/appx component-store corruption. Component-store corruption in `ShellExperienceHost`/`StartMenuExperienceHost`/`windowssearchengine`/Cortana is a *symptom*, not the cause; repairing it does NOT fix the boot loop. Read the actual cause first: `C:\Windows\System32\LogFiles\Srt\SrtTrail.txt`, and run `chkdsk C: /f` (disk errors are the #1 Automatic Repair cause).
|
||||
|
||||
**2. `FaultyPackageInProgress` + hundreds of "Install/Uninstall Pending" packages = a wedged cumulative-update transaction.** That IS the boot blocker. Confirm offline with `dism /Image:C:\ /Get-Packages /Format:Table` — look for many `Install Pending` / `Uninstall Pending` lines at the same timestamp (a half-applied CU swapping old build -> new build). Try to back it out with `dism /Image:C:\ /Cleanup-Image /RevertPendingActions`, or WinRE -> Troubleshoot -> Advanced -> Uninstall Updates -> "Uninstall latest quality update". If RevertPendingActions FAILS and there's no `pending.xml`, the transaction is too corrupt to repair in place -> clean install.
|
||||
|
||||
**3. Offline DISM won't accept a `wim:` source (`0x800f082e CBS_E_NOT_ALLOWED_OFFLINE`).** For `/Image:C:\ /Cleanup-Image /RestoreHealth` you must **mount** the install.wim and point `/Source` at the mounted folder's `\Windows`, not use `/Source:WIM:...:idx`:
|
||||
```
|
||||
mkdir C:\wimmount
|
||||
dism /Mount-Image /ImageFile:D:\sources\install.wim /Index:6 /MountDir:C:\wimmount /ReadOnly
|
||||
dism /Image:C:\ /Cleanup-Image /RestoreHealth /Source:C:\wimmount\Windows /LimitAccess /ScratchDir:G:\scratch
|
||||
dism /Unmount-Image /MountDir:C:\wimmount /Discard
|
||||
```
|
||||
Mount point must be an empty folder on a writable **NTFS local** drive (not the USB — Rufus UEFI sticks are FAT32 and hold the source itself). `0x800f0915` (repair content missing) usually means the offline backup store is gone and `/LimitAccess` blocked the WU fallback.
|
||||
|
||||
**4. Ventoy breaks WIM mounting (`0xc1420134` / "GetMountedImagesKey... file not found").** Ventoy serves the ISO through a virtual driver that also blocks `wim:` source access. Use a **Rufus**-built install stick instead — mounting then works.
|
||||
|
||||
**5. Build matters for the source: Win11 25H2 (build 26200) is 24H2 (build 26100) + enablement package; component store is 26100-versioned.** DISM matches on component version, not the marketing build, so the matching media for a 26200 box is a **24H2 / 26100 ISO**. Verify before repairing: `dism /Get-WimInfo /WimFile:<wim> /Index:6`.
|
||||
|
||||
Related: [[cyndyoffice-physical-hp-lockups]], [[feedback_rmm_system_context_mapped_drives]].
|
||||
268
.claude/scripts/backup_common.py
Normal file
268
.claude/scripts/backup_common.py
Normal file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared helpers for the backup-verification skills (seafile / owncloud / datto-workplace).
|
||||
|
||||
Three sibling skills answer the same GPS-audit question from different backends:
|
||||
"which customers/machines are actually backing up here?" Each skill has its own
|
||||
server-side client (Seafile Web API, ownCloud occ over SSH, Datto Workplace file
|
||||
API). What they SHARE — and what lives here — is:
|
||||
|
||||
* ClaudeTools repo-root resolution (env -> identity.json -> derived).
|
||||
* A one-field SOPS vault read via the canonical vault.sh wrapper.
|
||||
* An RmmClient for the "endpoint" half of the "server + RMM endpoint" cross-check:
|
||||
log in to GuruRMM, list agents, and run a detection PowerShell on an agent to
|
||||
prove a given sync client (SeaDrive / ownCloud / Datto Workplace) is actually
|
||||
installed and signed in on the machine.
|
||||
|
||||
Standalone: stdlib-only transport (urllib), no third-party dependency. Skills add
|
||||
this file's directory to sys.path and `import backup_common`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# backup_common.py lives at <root>/.claude/scripts/backup_common.py
|
||||
_THIS = Path(__file__).resolve()
|
||||
_DERIVED_ROOT = _THIS.parent.parent.parent # scripts -> .claude -> repo root
|
||||
|
||||
RMM_BASE = os.environ.get("GURURMM_API", "http://172.16.3.30:3001")
|
||||
RMM_VAULT_ENTRY = "infrastructure/gururmm-server.sops.yaml"
|
||||
RMM_FIELD_EMAIL = "credentials.gururmm-api.admin-email"
|
||||
RMM_FIELD_PASSWORD = "credentials.gururmm-api.admin-password"
|
||||
|
||||
HTTP_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""Transport / auth / API error, with an optional HTTP status."""
|
||||
|
||||
def __init__(self, message: str, *, status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
# --- repo-root + vault --------------------------------------------------------
|
||||
def resolve_root() -> Path:
|
||||
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived."""
|
||||
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
identity = _DERIVED_ROOT / ".claude" / "identity.json"
|
||||
if identity.exists():
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
root = data.get("claudetools_root")
|
||||
if root:
|
||||
return Path(root)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return _DERIVED_ROOT
|
||||
|
||||
|
||||
def vault_get_field(entry: str, field: str) -> str:
|
||||
"""Read one field from a SOPS vault entry via the canonical vault.sh wrapper."""
|
||||
root = resolve_root()
|
||||
vault = root / ".claude" / "scripts" / "vault.sh"
|
||||
if not vault.exists():
|
||||
raise BackupError(f"vault wrapper not found at {vault}")
|
||||
try:
|
||||
done = subprocess.run(
|
||||
["bash", str(vault), "get-field", entry, field],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise BackupError("'bash' not found on PATH (need Git Bash for vault reads).") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise BackupError(f"vault read timed out for {entry}:{field}") from exc
|
||||
if done.returncode != 0:
|
||||
raise BackupError(
|
||||
f"vault read failed for {entry}:{field} (exit {done.returncode}): {done.stderr.strip()}"
|
||||
)
|
||||
value = done.stdout.strip()
|
||||
if not value:
|
||||
raise BackupError(f"vault returned empty for {entry}:{field}")
|
||||
return value
|
||||
|
||||
|
||||
# --- minimal JSON HTTP --------------------------------------------------------
|
||||
def http_json(method: str, url: str, *, headers: Optional[dict] = None,
|
||||
body: Optional[dict] = None, form: Optional[dict] = None,
|
||||
timeout: float = HTTP_TIMEOUT,
|
||||
basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]:
|
||||
"""Do an HTTP request and parse a JSON response. Returns (status, parsed).
|
||||
|
||||
`body` sends a JSON payload; `form` sends application/x-www-form-urlencoded
|
||||
(list values expand via doseq, e.g. repeated fields the Seafile admin share
|
||||
endpoint reads with getlist). Pass at most one. 4xx/5xx are returned (not
|
||||
raised) so callers can inspect the error body; transport failures raise.
|
||||
"""
|
||||
hdrs = dict(headers or {})
|
||||
data = None
|
||||
if body is not None and form is not None:
|
||||
raise BackupError("http_json: pass body OR form, not both")
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
hdrs.setdefault("Content-Type", "application/json")
|
||||
elif form is not None:
|
||||
data = urllib.parse.urlencode(form, doseq=True).encode("utf-8")
|
||||
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||
if basic is not None:
|
||||
import base64
|
||||
tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii")
|
||||
hdrs["Authorization"] = f"Basic {tok}"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return resp.getcode(), _parse(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
return exc.code, _parse(raw)
|
||||
except urllib.error.URLError as exc:
|
||||
raise BackupError(f"request to {url} failed: {exc}") from exc
|
||||
|
||||
|
||||
def _parse(text: str) -> Any:
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": text[:600]}
|
||||
|
||||
|
||||
# --- GuruRMM client (endpoint cross-check) ------------------------------------
|
||||
# One detection script, parametrized by the product name-fragments to look for.
|
||||
# Returns a single JSON line describing installed products, running processes, and
|
||||
# per-user config folders that match — enough to say "this client is installed and
|
||||
# signed in" without guessing.
|
||||
_DETECT_PS = r'''
|
||||
$ErrorActionPreference='SilentlyContinue'
|
||||
$patterns = @(__PATTERNS__)
|
||||
function match($s){ if(-not $s){return $false}; foreach($p in $patterns){ if($s -match [regex]::Escape($p)){return $true} }; return $false }
|
||||
|
||||
$installed=@()
|
||||
foreach($hive in @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')){
|
||||
Get-ItemProperty $hive | Where-Object { match($_.DisplayName) } | ForEach-Object {
|
||||
$installed += [pscustomobject]@{ name=$_.DisplayName; version=$_.DisplayVersion; location=$_.InstallLocation }
|
||||
}
|
||||
}
|
||||
$procs = Get-Process | Where-Object { match($_.ProcessName) } | Select-Object -Expand ProcessName -Unique
|
||||
$cfg=@()
|
||||
foreach($d in @('Seafile','SeaDrive','ownCloud','ownCloudSync','Datto','Workplace','Workplace2')){
|
||||
foreach($base in @($env:APPDATA,$env:LOCALAPPDATA,$env:USERPROFILE,"$env:USERPROFILE\Desktop")){
|
||||
$pth = Join-Path $base $d
|
||||
if(Test-Path $pth){ if(match($d) -or match($pth)){ $cfg += $pth } }
|
||||
}
|
||||
}
|
||||
# signed-in heuristic: config/db files with recent writes
|
||||
$fresh=@()
|
||||
foreach($c in $cfg){
|
||||
Get-ChildItem $c -Recurse -File -ErrorAction SilentlyContinue -Depth 2 |
|
||||
Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-14) } |
|
||||
Select-Object -First 1 | ForEach-Object { $fresh += $c }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
host=$env:COMPUTERNAME
|
||||
installed=$installed
|
||||
processes=@($procs)
|
||||
config_dirs=@($cfg)
|
||||
active_config_dirs=@($fresh | Select-Object -Unique)
|
||||
detected=([bool]($installed.Count -or $procs.Count))
|
||||
} | ConvertTo-Json -Depth 5 -Compress
|
||||
'''
|
||||
|
||||
|
||||
class RmmClient:
|
||||
"""Thin GuruRMM API client: login, list agents, run a detection PowerShell."""
|
||||
|
||||
def __init__(self, base: str = RMM_BASE):
|
||||
self.base = base.rstrip("/")
|
||||
self._token: Optional[str] = None
|
||||
self._agents: Optional[list[dict]] = None
|
||||
|
||||
def login(self) -> None:
|
||||
email = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_EMAIL)
|
||||
pw = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_PASSWORD)
|
||||
status, body = http_json("POST", f"{self.base}/api/auth/login",
|
||||
body={"email": email, "password": pw})
|
||||
if status != 200 or not isinstance(body, dict) or not body.get("token"):
|
||||
raise BackupError(f"GuruRMM login failed (HTTP {status}): {body}", status=status)
|
||||
self._token = body["token"]
|
||||
|
||||
def _auth(self) -> dict:
|
||||
if not self._token:
|
||||
self.login()
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
|
||||
def list_agents(self) -> list[dict]:
|
||||
if self._agents is None:
|
||||
status, body = http_json("GET", f"{self.base}/api/agents", headers=self._auth())
|
||||
if status != 200 or not isinstance(body, list):
|
||||
raise BackupError(f"GET /api/agents failed (HTTP {status})", status=status)
|
||||
self._agents = body
|
||||
return self._agents
|
||||
|
||||
def find_agents(self, client_substr: Optional[str] = None,
|
||||
hostname_substr: Optional[str] = None) -> list[dict]:
|
||||
out = []
|
||||
for a in self.list_agents():
|
||||
if client_substr and client_substr.lower() not in (a.get("client_name") or "").lower():
|
||||
continue
|
||||
if hostname_substr and hostname_substr.lower() not in (a.get("hostname") or "").lower():
|
||||
continue
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
def run_ps(self, agent_id: str, script: str, timeout_seconds: int = 90,
|
||||
poll_seconds: int = 4, max_polls: int = 30) -> dict:
|
||||
"""Dispatch a PowerShell command to an agent and poll to completion."""
|
||||
status, body = http_json(
|
||||
"POST", f"{self.base}/api/agents/{agent_id}/command", headers=self._auth(),
|
||||
body={"command_type": "powershell", "command": script,
|
||||
"timeout_seconds": timeout_seconds})
|
||||
if status not in (200, 201) or not isinstance(body, dict):
|
||||
raise BackupError(f"command dispatch failed (HTTP {status}): {body}", status=status)
|
||||
cid = body.get("command_id") or body.get("id")
|
||||
if not cid:
|
||||
raise BackupError(f"no command_id in dispatch response: {body}")
|
||||
terminal = {"completed", "failed", "cancelled", "interrupted", "timeout"}
|
||||
last: dict = {}
|
||||
for _ in range(max_polls):
|
||||
s, cb = http_json("GET", f"{self.base}/api/commands/{cid}", headers=self._auth())
|
||||
if s == 200 and isinstance(cb, dict):
|
||||
last = cb
|
||||
if (cb.get("status") or "") in terminal:
|
||||
break
|
||||
time.sleep(poll_seconds)
|
||||
return last
|
||||
|
||||
def detect_client(self, agent: dict, patterns: list[str], **kw) -> dict:
|
||||
"""Run the detection script on one agent for the given product name-fragments."""
|
||||
pat_lits = ",".join("'" + p.replace("'", "''") + "'" for p in patterns)
|
||||
script = _DETECT_PS.replace("__PATTERNS__", pat_lits)
|
||||
res = self.run_ps(agent["id"], script, **kw)
|
||||
out = {"hostname": agent.get("hostname"), "client_name": agent.get("client_name"),
|
||||
"agent_id": agent.get("id"), "status": res.get("status"),
|
||||
"exit_code": res.get("exit_code"), "detected": None, "detail": None}
|
||||
stdout = (res.get("stdout") or "").strip()
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
out["detected"] = bool(parsed.get("detected"))
|
||||
out["detail"] = parsed
|
||||
except json.JSONDecodeError:
|
||||
out["detail"] = {"_raw": stdout[:600]}
|
||||
return out
|
||||
|
||||
|
||||
def eprint(*a: Any) -> None:
|
||||
print(*a, file=sys.stderr)
|
||||
@@ -99,17 +99,45 @@ if [ "$MODE" = "dm" ]; then
|
||||
TARGET="$CHID"
|
||||
fi
|
||||
|
||||
# --- post the message (printf | --data-binary @- — direct -d mangles multiline JSON) ---
|
||||
RESP="$(printf '%s' "$(jq -nc --arg c "$MSG" '{content:$c}')" | \
|
||||
curl -s -m 15 -w $'\n%{http_code}' "${auth[@]}" \
|
||||
-X POST "$API/channels/${TARGET}/messages" --data-binary @-)"
|
||||
HTTP="$(printf '%s' "$RESP" | tail -n1)"
|
||||
BODY="$(printf '%s' "$RESP" | sed '$d')"
|
||||
# --- chunk: Discord caps content at 2000 chars (code 50035 on overflow).
|
||||
# This tool's job is delivering LONG content intact, so split into <=1900-char
|
||||
# pieces (preferring newline boundaries) and send them in order — no markers
|
||||
# added, so the receiver can copy-paste the sequence back together verbatim.
|
||||
LIMIT=1900
|
||||
CHUNKS=()
|
||||
rest="$MSG"
|
||||
while [ "${#rest}" -gt "$LIMIT" ]; do
|
||||
piece="${rest:0:$LIMIT}"
|
||||
at_nl="${piece%$'\n'*}" # cut at the last newline in the window
|
||||
if [ "${#at_nl}" -lt "${#piece}" ] && [ "${#at_nl}" -gt 0 ]; then
|
||||
piece="$at_nl"
|
||||
fi
|
||||
CHUNKS+=("$piece")
|
||||
rest="${rest:${#piece}}"
|
||||
rest="${rest#$'\n'}"
|
||||
done
|
||||
CHUNKS+=("$rest")
|
||||
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
# --- post the message (jq | --data-binary @- — argv/-d mangles multiline + non-ASCII JSON) ---
|
||||
N=${#CHUNKS[@]}
|
||||
i=0
|
||||
for piece in "${CHUNKS[@]}"; do
|
||||
i=$((i+1))
|
||||
RESP="$(printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \
|
||||
curl -s -m 15 -w $'\n%{http_code}' "${auth[@]}" \
|
||||
-X POST "$API/channels/${TARGET}/messages" --data-binary @-)"
|
||||
HTTP="$(printf '%s' "$RESP" | tail -n1)"
|
||||
BODY="$(printf '%s' "$RESP" | sed '$d')"
|
||||
if [ "$HTTP" != "200" ]; then
|
||||
echo "[ERROR] discord-dm: Discord returned ${HTTP:-no-response} on chunk ${i}/${N} — ${BODY}" >&2
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "Discord send to $LABEL failed (chunk ${i}/${N})" --context "http=${HTTP:-none} resp=${BODY:0:80}" >/dev/null 2>&1
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$N" -gt 1 ]; then
|
||||
echo "[OK] discord-dm: sent to ${LABEL} in ${N} chunks (message_id=$(printf '%s' "$BODY" | jq -r '.id // empty'))"
|
||||
else
|
||||
echo "[OK] discord-dm: sent to ${LABEL} (message_id=$(printf '%s' "$BODY" | jq -r '.id // empty'))"
|
||||
exit 0
|
||||
fi
|
||||
echo "[ERROR] discord-dm: Discord returned ${HTTP:-no-response} — ${BODY}" >&2
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "Discord send to $LABEL failed" --context "http=${HTTP:-none} resp=${BODY:0:80}" >/dev/null 2>&1
|
||||
exit 3
|
||||
exit 0
|
||||
|
||||
35
.claude/scripts/gps-rmm-autoenroll.sh
Normal file
35
.claude/scripts/gps-rmm-autoenroll.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# gps-rmm-autoenroll.sh — auto-enroll loop for the GPS->RMM audit.
|
||||
# Every run: push the generic Staging installer (DARK-STORM-3150) to GPS-customer
|
||||
# machines that are ONLINE in ScreenConnect but missing from GuruRMM, wait for them
|
||||
# to enroll, then reassign Staging agents to their real client (hostname->Syncro).
|
||||
# Registered as Windows task GPS-RMM-AutoEnroll (every 30 min). Remove the task when
|
||||
# targets.json is fully enrolled. Safe to re-run: server v6.77+ dedups by device_id.
|
||||
set -uo pipefail
|
||||
cd /c/claudetools || exit 1
|
||||
LOG="projects/gps-rmm-audit/autoenroll.log"
|
||||
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"
|
||||
|
||||
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)"
|
||||
PUSHED="${PUSHED:-0}"
|
||||
echo "$TS sweep: $(echo "$OUT" | head -1)" >> "$LOG"
|
||||
|
||||
if [ "${PUSHED:-0}" -gt 0 ]; then
|
||||
sleep 150
|
||||
RES="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" python projects/gps-rmm-audit/tools/reassign-staging.py 2>/dev/null)"
|
||||
MOVED="$(echo "$RES" | grep -cE '^ .+ -> ' || true)"
|
||||
MOVED="${MOVED:-0}"
|
||||
echo "$TS pushed $PUSHED, reassigned $MOVED:" >> "$LOG"
|
||||
echo "$RES" | grep -E '^ ' >> "$LOG"
|
||||
if [ "${MOVED:-0}" -gt 0 ]; then
|
||||
NAMES="$(echo "$RES" | grep -E '^ .+ -> ' | sed 's/^ //' | tr '\n' '; ')"
|
||||
bash .claude/scripts/post-bot-alert.sh "[RMM] auto-enroll: $NAMES" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
63
.claude/scripts/gps-rmm-progress-check.sh
Normal file
63
.claude/scripts/gps-rmm-progress-check.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# gps-rmm-progress-check.sh — daily GPS->GuruRMM enrollment progress check.
|
||||
#
|
||||
# Reads projects/gps-rmm-audit/targets.json (GPS device target per client),
|
||||
# pulls live GuruRMM /api/agents, computes per-client enrollment gaps, and DMs
|
||||
# Howard a one-message summary. When every tracked client has met its target it
|
||||
# reports "COMPLETE" so the scheduled task can be retired.
|
||||
#
|
||||
# Usage:
|
||||
# bash gps-rmm-progress-check.sh # check + DM Howard
|
||||
# bash gps-rmm-progress-check.sh --dry-run # check + print, no Discord
|
||||
#
|
||||
# Registered as a daily Windows scheduled task. Read-only against RMM.
|
||||
set -u
|
||||
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
TARGETS="$ROOT/projects/gps-rmm-audit/targets.json"
|
||||
DRY=0; [ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
[ -f "$TARGETS" ] || { echo "[ERROR] targets.json not found at $TARGETS" >&2; exit 1; }
|
||||
|
||||
# --- auth + fetch agents ---
|
||||
eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh" 2>/dev/null)" >/dev/null
|
||||
if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then
|
||||
echo "[ERROR] RMM auth failed" >&2
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "gps-rmm-progress-check" "RMM auth failed" >/dev/null 2>&1
|
||||
exit 1
|
||||
fi
|
||||
AGENTS=$(curl -s "$RMM/api/agents" -H "Authorization: Bearer $TOKEN" | tr -d '\000-\037')
|
||||
[ "${AGENTS:0:1}" = "[" ] || { echo "[ERROR] /api/agents not an array: ${AGENTS:0:100}" >&2; \
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "gps-rmm-progress-check" "GET /api/agents non-array" >/dev/null 2>&1; exit 1; }
|
||||
|
||||
# --- per-client live agent counts (unique hostnames, to ignore dup agent rows) ---
|
||||
COUNTS=$(echo "$AGENTS" | jq -r '[.[] | {c:.client_name, h:(.hostname|ascii_downcase)}]
|
||||
| group_by(.c) | map({client:.[0].c, n:( [.[].h] | unique | length )}) | .[] | "\(.n)\t\(.client)"')
|
||||
|
||||
# --- compare targets vs live ---
|
||||
REPORT=""; DONE_ALL=1; TOTAL_TARGET=0; TOTAL_HAVE=0; GAP_CLIENTS=0
|
||||
while IFS=$'\t' read -r TGT NAME BUCKET; do
|
||||
[ -z "$NAME" ] && continue
|
||||
HAVE=$(echo "$COUNTS" | awk -F'\t' -v n="$NAME" '$2==n{print $1; found=1} END{if(!found)print 0}' | head -1)
|
||||
HAVE=${HAVE:-0}
|
||||
TOTAL_TARGET=$((TOTAL_TARGET + TGT))
|
||||
TOTAL_HAVE=$((TOTAL_HAVE + HAVE))
|
||||
if [ "$HAVE" -lt "$TGT" ]; then
|
||||
DONE_ALL=0; GAP_CLIENTS=$((GAP_CLIENTS+1))
|
||||
GAP=$((TGT - HAVE))
|
||||
STATE=$([ "$HAVE" -eq 0 ] && echo "NO ORG/AGENTS" || echo "short")
|
||||
REPORT="${REPORT} - ${NAME}: ${HAVE}/${TGT} in RMM (${STATE}, gap ${GAP}) [${BUCKET}]\n"
|
||||
fi
|
||||
done < <(jq -r '.clients[] | "\(.target)\t\(.client)\t\(.bucket)"' "$TARGETS")
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
if [ "$DONE_ALL" -eq 1 ]; then
|
||||
MSG="**GPS->RMM enrollment: COMPLETE (${DATE})** All tracked GPS clients meet their RMM device target (${TOTAL_HAVE}/${TOTAL_TARGET}). You can retire the daily check task (schtasks /Delete /TN GPS-RMM-Progress)."
|
||||
else
|
||||
MSG="**GPS->RMM enrollment check ${DATE}** — ${TOTAL_HAVE}/${TOTAL_TARGET} devices in RMM; ${GAP_CLIENTS} clients still short:\n${REPORT}(Glaz-Tech excluded pending billing review. Source: projects/gps-rmm-audit/targets.json)"
|
||||
fi
|
||||
|
||||
if [ "$DRY" -eq 1 ]; then
|
||||
echo -e "$MSG"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "$MSG" | bash "$ROOT/.claude/scripts/discord-dm.sh" howard
|
||||
@@ -23,6 +23,7 @@ set -u
|
||||
|
||||
TARGET="${1:-}"
|
||||
PHASE="${2:-all}"
|
||||
SCANNER_ARG="${3:-}"
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "[ERROR] Usage: bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>" >&2
|
||||
@@ -530,11 +531,70 @@ PS
|
||||
post_alert "[RMM] GuruScan verify-each on $AGENT_HOST complete - see per-engine detect/remove matrix"
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# scan-one <Scanner>: fully automated single-scanner test. ONE command does it
|
||||
# all, hands-off: restore the malware-lab samples, clear stale cleanup state,
|
||||
# run the scanner DETACHED + NO-CAP via the same path production uses, then
|
||||
# collect and report (detections, removal, results.json, reboot-cleanup task).
|
||||
# Mirrors how we proved Emsisoft -- no manual stitching.
|
||||
# ===========================================================================
|
||||
phase_scan_one() {
|
||||
local scanner="${SCANNER_ARG:-HitmanPro}"
|
||||
echo ""; echo "=== PHASE: scan-one ($scanner) - automated, detached, no-cap ==="
|
||||
|
||||
# Setup: free the mutex (kill any prior GuruScan run + scanner procs), clear
|
||||
# stale cleanup task/state, and restore the malware lab samples.
|
||||
local sf="$WORK_DIR/one_setup.ps1"
|
||||
cat > "$sf" <<'PS'
|
||||
$ErrorActionPreference='Continue'
|
||||
Get-ScheduledTask -EA SilentlyContinue | Where-Object { $_.TaskName -like 'GuruScan-*' } | ForEach-Object { try{ Stop-ScheduledTask -TaskName $_.TaskName -EA SilentlyContinue }catch{}; try{ Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -EA SilentlyContinue }catch{} }
|
||||
foreach($n in @('a2cmd','HitmanPro_x64','rkill','EmsisoftCommandlineScanner64')){ Get-Process -Name $n -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue }
|
||||
Start-Sleep 3
|
||||
Get-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -EA SilentlyContinue | Unregister-ScheduledTask -Confirm:$false -EA SilentlyContinue
|
||||
Remove-Item 'C:\GuruScan\cleanup-state.json' -Force -EA SilentlyContinue
|
||||
# Re-ensure scanners exist (a prior reboot-cleanup may have wiped C:\GuruScan\downloads).
|
||||
if(-not (Test-Path 'C:\GuruScan\downloads\EmsisoftCommandlineScanner64.exe') -or -not (Test-Path 'C:\GuruScan\downloads\rkill.exe')){
|
||||
& C:\GuruScan\Download-Scanners.ps1 *> $null
|
||||
}
|
||||
if(-not (Test-Path 'C:\GuruScan\downloads\HitmanPro_x64.exe')){
|
||||
try{ [Net.ServicePointManager]::SecurityProtocol=[Net.ServicePointManager]::SecurityProtocol -bor 3072; (New-Object Net.WebClient).DownloadFile('https://dl.surfright.nl/HitmanPro_x64.exe','C:\GuruScan\downloads\HitmanPro_x64.exe') }catch{}
|
||||
}
|
||||
$zip = Get-ChildItem 'C:\Users\Owner\Downloads' -Filter '*.zip' -EA SilentlyContinue | Where-Object { $_.Name -match 'malware' } | Select-Object -First 1
|
||||
if($zip){ Expand-Archive -Path $zip.FullName -DestinationPath 'C:\Users\Owner\Desktop' -Force -EA SilentlyContinue }
|
||||
$n=(Get-ChildItem 'C:\Users\Owner\Desktop\malware-samples-master' -Recurse -File -EA SilentlyContinue).Count
|
||||
Set-Content 'C:\GuruScan\_one_before.txt' $n
|
||||
Write-Output ("setup done - scanners ensured; malware samples present: $n")
|
||||
PS
|
||||
run_ps "$sf" 600 130 "setup" || { echo "[ERROR] setup failed"; return 1; }
|
||||
|
||||
# Run the scanner the production way: detached scheduled task, unlimited time.
|
||||
gs_launch_detached "-Scanners $scanner -Headless" "one" || { echo "[ERROR] launch failed"; return 1; }
|
||||
gs_wait_detached "one" "$scanner" || true
|
||||
|
||||
# Report the outcome (parsed from what GuruScan actually wrote).
|
||||
local rf="$WORK_DIR/one_report.ps1"
|
||||
cat > "$rf" <<'PS'
|
||||
$ErrorActionPreference='Continue'
|
||||
$before=Get-Content 'C:\GuruScan\_one_before.txt' -EA SilentlyContinue
|
||||
$after=(Get-ChildItem 'C:\Users\Owner\Desktop\malware-samples-master' -Recurse -File -EA SilentlyContinue).Count
|
||||
Write-Output ("samples: before=$before after=$after REMOVED=" + ([int]$before-[int]$after))
|
||||
$d=Get-ChildItem 'C:\ScanLogs' -Directory -EA SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if($d -and (Test-Path (Join-Path $d.FullName 'results.json'))){ $r=Get-Content (Join-Path $d.FullName 'results.json') -Raw|ConvertFrom-Json
|
||||
Write-Output ('results.json -> total_threats=' + $r.total_threats + ' reboot_required=' + $r.reboot_required)
|
||||
$r.scanners|ForEach-Object{ Write-Output (' ' + $_.name + ': exit=' + $_.exit_code + ' threats=' + $_.threats_found) } }
|
||||
$ct=Get-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -EA SilentlyContinue
|
||||
if($ct){ Write-Output ('reboot-cleanup task -> REGISTERED (state=' + $ct.State + ', logon-delay=' + $ct.Triggers[0].Delay + ')') } else { Write-Output 'reboot-cleanup task -> NOT registered' }
|
||||
PS
|
||||
run_ps "$rf" 60 24 "report" || true
|
||||
post_alert "[RMM] GuruScan automated scan-one ($scanner) complete on $AGENT_HOST"
|
||||
}
|
||||
|
||||
case "$PHASE" in
|
||||
prep) phase_prep ;;
|
||||
scan) phase_scan ;;
|
||||
scan-one) phase_scan_one ;;
|
||||
collect) phase_collect ;;
|
||||
verify-each) phase_verify_each ;;
|
||||
all) phase_prep && phase_scan && phase_collect ;;
|
||||
*) echo "[ERROR] Unknown phase '$PHASE' (prep|scan|collect|verify-each|all)" >&2; exit 1 ;;
|
||||
*) echo "[ERROR] Unknown phase '$PHASE' (prep|scan|scan-one <Scanner>|collect|verify-each|all)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
# Writes: YYYY-MM-DD | MACHINE | <skill> | [<type>] <error> [ctx: <context>]
|
||||
# (newest entry inserted at the top, just under the append marker).
|
||||
#
|
||||
# Dedup: if an IDENTICAL entry (same date, machine, skill, message) already
|
||||
# exists, no new line is added — the existing line gets a " (xN)" repeat counter
|
||||
# bumped instead. Identical machine-generated failures (API retry loops) collapse
|
||||
# to one line per day; a different message/context/date is still a new entry.
|
||||
#
|
||||
# Soft-fail by design: this NEVER breaks the caller. Missing log, missing jq,
|
||||
# empty message -> prints a [WARN] to stderr and exits 0.
|
||||
set -u
|
||||
@@ -62,7 +67,7 @@ DATE="$(date -u +%F)"
|
||||
IDF="$ROOT/.claude/identity.json"
|
||||
MACHINE=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$IDF" ]; then
|
||||
MACHINE="$(jq -r '.machine_name // .hostname // empty' "$IDF" 2>/dev/null)"
|
||||
MACHINE="$(jq -r '.machine // .machine_name // .hostname // empty' "$IDF" 2>/dev/null)"
|
||||
fi
|
||||
[ -z "$MACHINE" ] && MACHINE="$(hostname 2>/dev/null || echo unknown)"
|
||||
|
||||
@@ -76,6 +81,34 @@ ENTRY="$DATE | $MACHINE | $SKILL | $MSG"
|
||||
|
||||
MARK="<!-- Append entries below this line -->"
|
||||
TMP="$LOG.tmp.$$"
|
||||
|
||||
# Dedup pass: an identical entry today (bare, or already counted "(xN)") gets its
|
||||
# repeat counter bumped in place instead of a duplicate line. Literal string
|
||||
# compares only — the message may contain regex metacharacters.
|
||||
DEDUP_RC=1
|
||||
awk -v entry="$ENTRY" '
|
||||
!bumped && $0 == entry { print entry " (x2)"; bumped=1; next }
|
||||
!bumped && index($0, entry " (x") == 1 {
|
||||
n = substr($0, length(entry) + 4) # text after " (x"
|
||||
if (n ~ /^[0-9]+\)$/) {
|
||||
sub(/\)$/, "", n)
|
||||
print entry " (x" n+1 ")"; bumped=1; next
|
||||
}
|
||||
}
|
||||
{ print }
|
||||
END { exit bumped ? 0 : 1 }
|
||||
' "$LOG" > "$TMP" 2>/dev/null && DEDUP_RC=0
|
||||
if [ "$DEDUP_RC" -eq 0 ]; then
|
||||
if mv "$TMP" "$LOG" 2>/dev/null; then
|
||||
echo "[OK] duplicate entry — bumped repeat counter in errorlog.md ($SKILL)"
|
||||
else
|
||||
rm -f "$TMP" 2>/dev/null
|
||||
echo "[WARN] log-skill-error: could not write $LOG" >&2
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$TMP" 2>/dev/null
|
||||
|
||||
if awk -v entry="$ENTRY" -v mark="$MARK" '
|
||||
{ print }
|
||||
($0==mark && !done) { print ""; print entry; done=1 }
|
||||
|
||||
@@ -35,6 +35,13 @@ if [ -z "$MSG" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Discord caps message content at 2000 chars (code 50035 on overflow). Alerts
|
||||
# are one-liners by contract — truncate rather than chunk.
|
||||
if [ "${#MSG}" -gt 1900 ]; then
|
||||
MSG="${MSG:0:1900} ...[truncated]"
|
||||
echo "[WARNING] post-bot-alert: message over 1900 chars — truncated" >&2
|
||||
fi
|
||||
|
||||
# --- channel routing ---
|
||||
# Optional 2nd arg: "dev"/"bot" keyword, a raw channel id, or omit for auto.
|
||||
# Auto: RMM/Dev-category prefixes -> #dev-alerts (private); everything else
|
||||
@@ -67,14 +74,15 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- post (jq builds JSON so the message is safely escaped) ---
|
||||
PAYLOAD="$(jq -nc --arg c "$MSG" '{content: $c}')"
|
||||
RESP="$(curl -s -m 15 -w $'\n%{http_code}' \
|
||||
# --- post (jq builds the JSON; piped to curl via STDIN, never argv — a payload
|
||||
# passed as a command-line arg on Windows goes through the argv encoding layer,
|
||||
# which mangles non-ASCII chars into invalid JSON -> Discord 50109) ---
|
||||
RESP="$(jq -nc --arg c "$MSG" '{content: $c}' | curl -s -m 15 -w $'\n%{http_code}' \
|
||||
-X POST "https://discord.com/api/v10/channels/${CHANNEL_ID}/messages" \
|
||||
-H "Authorization: Bot ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: ClaudeToolsBot (claudetools, 1.0)" \
|
||||
--data-binary "$PAYLOAD" 2>/dev/null)"
|
||||
--data-binary @- 2>/dev/null)"
|
||||
|
||||
HTTP="$(printf '%s' "$RESP" | tail -n1)"
|
||||
BODY="$(printf '%s' "$RESP" | sed '$d')"
|
||||
|
||||
141
.claude/scripts/ps-encoded.sh
Normal file
141
.claude/scripts/ps-encoded.sh
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# ps-encoded.sh — dispatch PowerShell through quote-mangling layers safely.
|
||||
#
|
||||
# THE point of this helper: a PS payload that traverses CommandLineToArgvW
|
||||
# (curl.exe args, plink, ScreenConnect's command box, RMM->cmd.exe) gets its
|
||||
# embedded double-quotes stripped and its UNC backslashes halved — the single
|
||||
# most-repeated friction class in errorlog.md (ref=feedback_windows_quote_stripping,
|
||||
# 9+ hits). -EncodedCommand (UTF-16LE base64) has NO quotes, backslashes, or
|
||||
# dollar signs to mangle, so the script arrives byte-exact. Write the script
|
||||
# to a FILE (or stdin), let this helper encode + deliver it.
|
||||
#
|
||||
# Usage:
|
||||
# ps-encoded.sh encode <script.ps1|-> print the one-liner for
|
||||
# ScreenConnect / plink / any paste
|
||||
# ps-encoded.sh rmm <agent-uuid> <script.ps1|-> dispatch via GuruRMM + poll
|
||||
# [--timeout <sec>] agent-side timeout_seconds (default 120)
|
||||
# [--user-session] context: user_session (WTS-impersonated desktop user)
|
||||
# [--no-wait] dispatch only; print command id, skip polling
|
||||
# [--force] override the size refusal (see below)
|
||||
#
|
||||
# Size guards (agent chokes on ~7KB command bodies; encoding inflates ~2.67x):
|
||||
# encoded > 4000 chars -> [WARNING]; encoded > 6000 chars -> refuse unless
|
||||
# --force (split the script, or stage it on the endpoint and run the file).
|
||||
#
|
||||
# Exit codes: 0 ok; 1 usage/encode error; 2 size refusal; 3 dispatch/poll failure.
|
||||
|
||||
set -u
|
||||
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
_logerr() { bash "$ROOT/.claude/scripts/log-skill-error.sh" "ps-encoded" "$@" >/dev/null 2>&1 || true; }
|
||||
|
||||
usage() { sed -n '2,26p' "$0"; exit 1; }
|
||||
|
||||
read_script() { # $1 = file path or "-"
|
||||
if [ "$1" = "-" ]; then cat
|
||||
elif [ -f "$1" ]; then cat "$1"
|
||||
else echo "[ERROR] script file not found: $1" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps)
|
||||
iconv -f UTF-8 -t UTF-16LE | base64 -w0
|
||||
}
|
||||
|
||||
size_gate() { # $1 = encoded string, $2 = force flag (0/1)
|
||||
local n=${#1}
|
||||
if [ "$n" -gt 6000 ] && [ "${2:-0}" != "1" ]; then
|
||||
echo "[ERROR] encoded payload is ${n} chars (>6000); the agent fails on bodies this large." >&2
|
||||
echo " Split the script into sections, or stage it as a file on the endpoint" >&2
|
||||
echo " and dispatch 'powershell -File <path>'. Override with --force." >&2
|
||||
return 1
|
||||
elif [ "$n" -gt 4000 ]; then
|
||||
echo "[WARNING] encoded payload is ${n} chars — near the agent's body-size failure zone (~7KB)." >&2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_encode() {
|
||||
local src="${1:-}"; [ -z "$src" ] && usage
|
||||
local b64
|
||||
b64="$(read_script "$src" | encode_b64)"
|
||||
[ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; }
|
||||
size_gate "$b64" 1 || true # encode mode: warn only, the paste target may cope
|
||||
printf 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand %s\n' "$b64"
|
||||
}
|
||||
|
||||
cmd_rmm() {
|
||||
local agent="${1:-}"; shift || true
|
||||
local src="${1:-}"; shift || true
|
||||
[ -z "$agent" ] || [ -z "$src" ] && usage
|
||||
local timeout=120 context="" wait=1 force=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--timeout) timeout="${2:?}"; shift 2 ;;
|
||||
--user-session) context="user_session"; shift ;;
|
||||
--no-wait) wait=0; shift ;;
|
||||
--force) force=1; shift ;;
|
||||
*) echo "[ERROR] unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local b64
|
||||
b64="$(read_script "$src" | encode_b64)"
|
||||
[ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; }
|
||||
size_gate "$b64" "$force" || exit 2
|
||||
|
||||
eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh")"
|
||||
if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then
|
||||
echo "[ERROR] RMM auth failed (no TOKEN/RMM)" >&2
|
||||
_logerr "RMM auth failed via rmm-auth.sh" --context "agent=$agent"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# command_type=shell (cmd.exe): the base64 blob is a single quote-free token,
|
||||
# so no layer between here and powershell.exe can mangle it. timeout_seconds
|
||||
# is the field the agent honors — 'timeout' is silently ignored (errorlog).
|
||||
local oneliner payload resp cmd_id status
|
||||
oneliner="powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${b64}"
|
||||
payload="$(jq -nc --arg ct shell --arg cmd "$oneliner" --argjson to "$timeout" \
|
||||
--arg cx "$context" \
|
||||
'{command_type:$ct, command:$cmd, timeout_seconds:$to}
|
||||
+ (if $cx != "" then {context:$cx} else {} end)')"
|
||||
resp="$(printf '%s' "$payload" | curl -s -m 20 -X POST "$RMM/api/agents/$agent/command" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @-)"
|
||||
cmd_id="$(printf '%s' "$resp" | jq -r '.command_id // empty' 2>/dev/null)"
|
||||
status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)"
|
||||
if [ -z "$cmd_id" ]; then
|
||||
echo "[ERROR] dispatch failed: $resp" >&2
|
||||
_logerr "EncodedCommand dispatch failed" --context "agent=$agent resp=${resp:0:80}"
|
||||
exit 3
|
||||
fi
|
||||
echo "[OK] dispatched command_id=$cmd_id (initial status: ${status:-unknown}, timeout_seconds=$timeout)"
|
||||
[ "$wait" = "0" ] && exit 0
|
||||
|
||||
local max_polls=$(( (timeout + 30) / 5 )) count=0 result
|
||||
while [ $count -lt $max_polls ]; do
|
||||
result="$(curl -s -m 15 "$RMM/api/commands/$cmd_id" -H "Authorization: Bearer $TOKEN")"
|
||||
status="$(printf '%s' "$result" | jq -r '.status // empty' 2>/dev/null)"
|
||||
case "$status" in
|
||||
completed|failed|cancelled|interrupted)
|
||||
echo "--- status: $status ---"
|
||||
printf '%s' "$result" | jq -r '
|
||||
"exit_code: \(.exit_code // "n/a")",
|
||||
"--- stdout ---", (.stdout // .output // ""),
|
||||
"--- stderr ---", (.stderr // "")' 2>/dev/null || printf '%s\n' "$result"
|
||||
[ "$status" = "completed" ] && exit 0 || exit 3 ;;
|
||||
running|pending) count=$((count+1)); sleep 5 ;;
|
||||
*) echo "[ERROR] empty/unknown status — response: $result" >&2
|
||||
_logerr "poll returned empty status" --context "cmd=$cmd_id agent=$agent"
|
||||
exit 3 ;;
|
||||
esac
|
||||
done
|
||||
echo "[WARNING] poll timeout after $((max_polls*5))s — command $cmd_id may still be running (last: $status)"
|
||||
exit 3
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
encode) shift; cmd_encode "$@" ;;
|
||||
rmm) shift; cmd_rmm "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -145,6 +145,50 @@ resolve_submodule_collisions() {
|
||||
[ "$moved" -eq 1 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# Advance submodules to the parent's pinned gitlinks after a parent pull, WITHOUT
|
||||
# ever clobbering a submodule that has local work. The pristine/pinned state of a
|
||||
# submodule is a CLEAN, DETACHED HEAD; a developer actively working in a submodule
|
||||
# checks out a BRANCH and/or has uncommitted edits. Those we skip entirely so an
|
||||
# in-progress feature inside a submodule survives a parent sync.
|
||||
#
|
||||
# (Friction 2026-06-22: an unguarded `git submodule update --init --recursive`
|
||||
# after the parent rebase repeatedly reset guru-rmm to its pinned commit — detaching
|
||||
# HEAD and discarding a pushed-elsewhere feature branch + commits mid-build. The
|
||||
# Phase-1a init guard at the top did not cover this post-rebase reconcile path.)
|
||||
#
|
||||
# Protected-submodule notices go to stderr; git's own output goes to stdout so the
|
||||
# caller can capture it for failure reporting. Returns git's rc (0 if nothing to do).
|
||||
submodule_update_safe() {
|
||||
[ -f ".gitmodules" ] || return 0
|
||||
local p safe=() prot=()
|
||||
while read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
if [ -e "$p/.git" ]; then
|
||||
# On a branch (not the pristine detached pin) -> someone is working here.
|
||||
if git -C "$p" symbolic-ref -q HEAD >/dev/null 2>&1; then
|
||||
prot+=("$p [branch $(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null)]")
|
||||
continue
|
||||
fi
|
||||
# Uncommitted changes -> protect.
|
||||
if [ -n "$(git -C "$p" status --porcelain 2>/dev/null)" ]; then
|
||||
prot+=("$p [uncommitted changes]")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
# Clean detached HEAD, or not yet populated (fresh clone) -> safe to update.
|
||||
safe+=("$p")
|
||||
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null | awk '{print $2}')
|
||||
|
||||
local item
|
||||
for item in "${prot[@]}"; do
|
||||
echo -e "${YELLOW}[INFO]${NC} sync: leaving submodule with local work untouched: ${item}" >&2
|
||||
done
|
||||
|
||||
[ "${#safe[@]}" -eq 0 ] && return 0
|
||||
git submodule update --init --recursive --quiet -- "${safe[@]}" 2>&1
|
||||
return $?
|
||||
}
|
||||
|
||||
# Machine + timestamp
|
||||
if [ -n "$COMPUTERNAME" ]; then
|
||||
MACHINE="$COMPUTERNAME"
|
||||
@@ -522,14 +566,14 @@ if [ "$INCOMING_COUNT" -gt 0 ]; then
|
||||
# raw "fatal: Unable to checkout" reads alarmingly right before a clean resolve.
|
||||
# Only surface the raw output if the resolve+retry below also fails.
|
||||
set +e
|
||||
SUB_OUT=$(git submodule update --init --recursive --quiet 2>&1)
|
||||
SUB_OUT=$(submodule_update_safe)
|
||||
SUB_RC=$?
|
||||
set -e
|
||||
if [ "$SUB_RC" -ne 0 ]; then
|
||||
# Move any untracked files colliding with the incoming commit aside, retry once.
|
||||
if resolve_submodule_collisions; then
|
||||
set +e
|
||||
SUB_OUT=$(git submodule update --init --recursive --quiet 2>&1)
|
||||
SUB_OUT=$(submodule_update_safe)
|
||||
SUB_RC=$?
|
||||
set -e
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
"type": "command",
|
||||
"command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-backslash-winpath.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-tmp-path.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,51 +1,68 @@
|
||||
---
|
||||
name: agy
|
||||
description: >
|
||||
Route a task to the official Google Gemini CLI for an independent second model — a
|
||||
sibling of the `grok` second-opinion router. Use for a different-vendor SECOND OPINION
|
||||
or adversarial VERIFICATION of a Claude finding/design, a Gemini code REVIEW of files /
|
||||
a git diff, and one-shot Gemini TEXT answers. Triggers: ask gemini, gemini verify,
|
||||
second opinion from gemini, gemini review, agy ... A second model, NOT a replacement
|
||||
for Claude's own codebase work.
|
||||
description: "Route a task to the Google Antigravity CLI (agy) for an independent second model (sibling of grok): different-vendor second opinion, adversarial verification, code review of files/diffs, vision, live web search, one-shot text answers. Triggers: ask gemini, ask agy, agy verify, agy review, agy."
|
||||
---
|
||||
|
||||
# AGY — Gemini capability router
|
||||
# AGY — Antigravity CLI capability router
|
||||
|
||||
Claude shells out to the locally-installed **Google Gemini CLI** (`gemini`, npm
|
||||
global, v0.45.2) for a genuinely independent, different-vendor second model.
|
||||
AGY is the sibling of [`grok`](../grok/SKILL.md): both are second-opinion /
|
||||
review routers. Use whichever you want a second model from (or both, to triangulate).
|
||||
Verified working on this machine (2026-06-05; re-validated 2026-06-17 against the
|
||||
CLI's bundled help/README — JSON schema, all flags, pinned model, and live search
|
||||
all confirmed): text, verify, review (single file / file set / git diff),
|
||||
image-analyze (vision input), search (live Google web search). All KEYLESS — they
|
||||
work on Google OAuth, no API key.
|
||||
Claude shells out to the locally-installed **Google Antigravity CLI** (`agy`, a
|
||||
native Go binary at `~/AppData/Local/agy/bin/agy`, v1.0.16) for a genuinely
|
||||
independent, different-vendor second model. AGY is the sibling of
|
||||
[`grok`](../grok/SKILL.md): both are second-opinion / review routers. Use whichever
|
||||
you want a second model from (or both, to triangulate). Modes verified on this
|
||||
machine (2026-07-02): text, verify, review (single file / file set / git diff),
|
||||
image-analyze (vision), search (live web search + source URLs).
|
||||
|
||||
**Auth:** Gemini uses **Google login (OAuth)** — **no API key**. Creds live at
|
||||
`~/.gemini/oauth_creds.json`. If calls fail with an auth error, run `gemini`
|
||||
interactively once and choose **"Login with Google"**, then retry.
|
||||
> **REWIRED 2026-07-02 — `agy` replaces the old `gemini` npm CLI.** The former
|
||||
> Google `gemini` CLI (npm `@google/gemini-cli`) stopped working on this account
|
||||
> with a Cloud-project eligibility error (`throwIneligibleOrProjectIdError` — it
|
||||
> demanded a `GOOGLE_CLOUD_PROJECT` the personal account couldn't provide). The
|
||||
> Antigravity CLI (`agy`) uses Antigravity's OWN auth and works headless with no
|
||||
> project ID. The wrapper is `scripts/ask-agy.sh`; `scripts/ask-gemini.sh` is kept
|
||||
> as a thin **deprecated shim** that `exec`s ask-agy.sh so old references still work.
|
||||
|
||||
**Backend models (multi-vendor, `--model`):** `agy models` lists them — Gemini 3.5
|
||||
Flash (Low/Medium/High), Gemini 3.1 Pro (Low/High), Claude Sonnet 4.6 (Thinking),
|
||||
Claude Opus 4.6 (Thinking), GPT-OSS 120B. `text` uses the CLI default (Flash, fast);
|
||||
`verify`/`review*`/`image-analyze`/`search` pin the strong model
|
||||
`Gemini 3.1 Pro (High)`. Override any mode with `AGY_MODEL="<friendly name>"`.
|
||||
|
||||
**Auth:** Antigravity's own login (`~/.gemini/antigravity-cli/`) — **no API key, no
|
||||
`GOOGLE_CLOUD_PROJECT`**. If a call fails with an auth error, run `agy` interactively
|
||||
once to sign in, then retry.
|
||||
|
||||
## The wrapper
|
||||
|
||||
```
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/skills/agy/scripts/ask-gemini.sh" <mode> ...
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/skills/agy/scripts/ask-agy.sh" <mode> ...
|
||||
```
|
||||
|
||||
Authoritative headless flags (from `agy --help`, Go flag package — the web docs at
|
||||
antigravity.google/docs/cli are JS-rendered SPAs and unreadable by fetch tools, so
|
||||
`--help` is the source of truth): `-p/--print/--prompt` runs one prompt and prints
|
||||
the response (**the prompt is the flag's VALUE and MUST be last** — a bare `-p --model`
|
||||
makes "--model" the prompt); `--model "<name>"`; `--add-dir <dir>` (repeatable, gives
|
||||
the file tools access to a review/vision target); `--dangerously-skip-permissions`
|
||||
(auto-approve tool calls — without it print mode HANGS on an approval prompt);
|
||||
`--print-timeout <dur>` (default 5m). There is **no JSON output flag** — stdout is
|
||||
plain text/markdown, so the wrapper does no JSON parsing.
|
||||
|
||||
| Mode | Usage | What it does |
|
||||
|------|-------|--------------|
|
||||
| `text` | `ask-gemini.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer from an independent model. `--prompt-file` for long content (review/summarize a doc). Default model routing. |
|
||||
| `verify` | `ask-gemini.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Gemini tries to REFUTE / find gaps, returns a verdict + reasons. Pinned to the strong model. |
|
||||
| `review` | `ask-gemini.sh review <file-path> ["<instructions>"]` | Gemini reads the file itself (its `read_file` tool, read-only `plan` mode) and reviews it. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. Works even on gitignored files. |
|
||||
| `review-files` | `ask-gemini.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (cross-file consistency, multi-file change). Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg. |
|
||||
| `review-diff` | `ask-gemini.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). Diff goes via the prompt file; Gemini can `read_file` changed files for full context. |
|
||||
| `image-analyze` | `ask-gemini.sh image-analyze <image-path> ["<question>"]` | **Vision** — Gemini `read_file`s the image and describes/answers about it. Pins the **pro vision model** (the default flash-lite router hallucinates image content). Path absolute or repo-relative; spaces OK. KEYLESS (works on OAuth). |
|
||||
| `search` | `ask-gemini.sh search "<query>"` (or `search --prompt-file <path>`) | **Live Google web search** (sibling of `grok xsearch`) — Gemini uses its `google_web_search` tool and returns the answer **with source URLs**. KEYLESS (works on OAuth). |
|
||||
| `raw` | `ask-gemini.sh raw <gemini args...>` | Escape hatch — passes args straight to `gemini`. |
|
||||
| `text` | `ask-agy.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer from an independent model. `--prompt-file` for long content (review/summarize a doc). Default model routing. |
|
||||
| `verify` | `ask-agy.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Gemini tries to REFUTE / find gaps, returns a verdict + reasons. Pinned to the strong model. |
|
||||
| `review` | `ask-agy.sh review <file-path> ["<instructions>"]` | agy reads the file itself (copied into an `--add-dir` workspace) and reviews it. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. Works even on gitignored files. |
|
||||
| `review-files` | `ask-agy.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (cross-file consistency, multi-file change). Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg. |
|
||||
| `review-diff` | `ask-agy.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). Diff goes via the prompt file; Gemini can `read_file` changed files for full context. |
|
||||
| `image-analyze` | `ask-agy.sh image-analyze <image-path> ["<question>"]` | **Vision** — agy reads the image (via `--add-dir`) and describes/answers about it. Pinned to the strong model. Path absolute or repo-relative; spaces OK. |
|
||||
| `search` | `ask-agy.sh search "<query>"` (or `search --prompt-file <path>`) | **Live web search** (sibling of `grok xsearch`) — agy searches the web and returns the answer **with source URLs**. |
|
||||
| `raw` | `ask-agy.sh raw <agy args...>` | Escape hatch — passes args straight to `agy`. |
|
||||
|
||||
The script runs Gemini headless with `-o json`, extracts the answer from
|
||||
`.response` (parsing from the first `{` so the CLI's cosmetic warning lines are
|
||||
ignored), and keeps stderr separate from the JSON so 429-backoff / warning noise
|
||||
never corrupts the parse.
|
||||
The script runs `agy` headless (`--dangerously-skip-permissions ... -p "<prompt>"`),
|
||||
captures plain-text stdout (no JSON — agy has no structured-output mode), keeps stderr
|
||||
separate for diagnostics, and retries a couple times with backoff on an empty turn.
|
||||
For review/vision it copies the target into a temp dir and `--add-dir`s it so agy's
|
||||
file tools can read it regardless of location/spaces.
|
||||
|
||||
> [!WARNING]
|
||||
> **Path gotcha for `review` / `review-files` (this has bitten us repeatedly).**
|
||||
@@ -61,96 +78,84 @@ never corrupts the parse.
|
||||
|
||||
### Model
|
||||
|
||||
- `text` uses Gemini's **default routing** (currently a flash-tier model) — fast, cheap.
|
||||
- `verify` / `review*` pin a **strong** model — `gemini-3.1-pro-preview` (verified
|
||||
available 2026-06-05, still valid 2026-06-17; the GA-looking `gemini-3.1-pro` and
|
||||
`gemini-3-pro` both `ModelNotFoundError`, so keep the `-preview` suffix).
|
||||
- Override either with `GEMINI_MODEL=<id>` (e.g. `GEMINI_MODEL=gemini-2.5-pro`).
|
||||
- `image-analyze` and `search` also pin the strong model (`GEMINI_MODEL` still honored).
|
||||
- `text` uses the CLI **default** (Gemini 3.5 Flash) — fast, cheap.
|
||||
- `verify` / `review*` / `image-analyze` / `search` pin the strong model
|
||||
**`Gemini 3.1 Pro (High)`** via `--model`.
|
||||
- Override any mode with `AGY_MODEL="<friendly name>"` (exact strings from
|
||||
`agy models`, e.g. `AGY_MODEL="Claude Opus 4.6 (Thinking)"`).
|
||||
|
||||
### Multimodal: image INPUT works, image GENERATION does not
|
||||
### Vision
|
||||
|
||||
- **Image INPUT (vision) works on OAuth** — `image-analyze` reads an image with the
|
||||
pinned **pro vision model** and describes it correctly. The default flash-lite
|
||||
router HALLUCINATES image content, which is why the pro model is pinned.
|
||||
- **Image GENERATION (nano-banana) does NOT work on OAuth** — it needs a Google AI
|
||||
Studio `NANOBANANA_API_KEY` plus the `nanobanana` extension. **Deferred** for now.
|
||||
Image/video **generation** stays [GROK](../grok/SKILL.md)'s lane (`grok image` /
|
||||
`grok video`); AGY's multimodal support is read/analyze only.
|
||||
`image-analyze` copies the image into an `--add-dir` workspace and asks agy to read
|
||||
and describe it (pinned to the strong model). Report-only — describes an image you
|
||||
give it. Image/video **generation** stays [GROK](../grok/SKILL.md)'s lane
|
||||
(`grok image` / `grok video`).
|
||||
|
||||
## Machine availability (fleet)
|
||||
|
||||
AGY is **per-machine** — the skill syncs fleet-wide but the `gemini` binary does
|
||||
not. Availability is gated by `identity.json` (per-machine, gitignored):
|
||||
AGY is **per-machine** — the skill syncs fleet-wide but the `agy` binary does not.
|
||||
Availability is gated by `identity.json` (per-machine, gitignored):
|
||||
|
||||
```json
|
||||
"gemini": { "installed": true,
|
||||
"binary": "C:/Users/guru/AppData/Roaming/npm/gemini",
|
||||
"auth": "oauth", "is_fleet_host": true,
|
||||
"capabilities": ["text","verify","review","image-analyze","search"] }
|
||||
"agy": { "installed": true,
|
||||
"binary": "C:/Users/guru/AppData/Local/agy/bin/agy",
|
||||
"auth": "antigravity", "is_fleet_host": true,
|
||||
"capabilities": ["text","verify","review","review-files","review-diff","image-analyze","search"] }
|
||||
```
|
||||
|
||||
- If `gemini.installed` is `false` (or the block is absent), `ask-gemini.sh` exits
|
||||
**3** with routing guidance instead of failing obscurely. Claude on such a
|
||||
machine should NOT attempt local Gemini.
|
||||
- **Fleet Gemini hosts: `GURU-5070`, `GURU-BEAST-ROG`** — machines with the Gemini
|
||||
CLI installed and Google-OAuth'd. When others get it, install
|
||||
`@google/gemini-cli`, run `gemini` once to log in with Google, then set their
|
||||
`identity.json` `gemini` block (and update this line).
|
||||
- If `agy.installed` is `false` (or the block is absent), `ask-agy.sh` exits **3**
|
||||
with routing guidance instead of failing obscurely. Claude on such a machine
|
||||
should NOT attempt local agy. (The wrapper falls back to the legacy `.gemini`
|
||||
block if `.agy` is absent, for machines mid-migration.)
|
||||
- **Fleet host: `GURU-5070`** (agy installed + Antigravity-authed). When others get
|
||||
it, install the Antigravity CLI, run `agy` once to sign in, then set their
|
||||
`identity.json` `agy` block (and update this line).
|
||||
|
||||
**Remote routing (NOT yet wired):** a non-host machine cannot run Gemini locally.
|
||||
To fulfill an AGY request from elsewhere, route it to the host (`GURU-5070`) —
|
||||
same pending channels as Grok (GuruRMM agent exec, a relay, or a coord-API job
|
||||
queue). Until that's built, AGY requests originate on the host machine.
|
||||
**Remote routing (NOT yet wired):** a non-host machine cannot run agy locally. Route
|
||||
the request to the host (`GURU-5070`) — same pending channels as Grok (GuruRMM agent
|
||||
exec, a relay, or a coord-API job queue). Until built, AGY requests originate on the host.
|
||||
|
||||
## When to route to Gemini (AGY)
|
||||
## When to route to AGY
|
||||
|
||||
- **Independent verification** — a genuinely different vendor/model to red-team a
|
||||
Claude finding or design before acting on it. (`verify`)
|
||||
- **Second-model code review** — have Gemini read and critique a file, a set of
|
||||
files, or a diff independently of Claude. (`review`, `review-files`, `review-diff`)
|
||||
- **Diverse drafts / second opinion** — alternative phrasing or approach to
|
||||
compare. (`text`)
|
||||
- **Google-ecosystem reach** — when a Google-side model/behavior is specifically
|
||||
wanted as the comparison point.
|
||||
- **Independent verification** — a different vendor/model to red-team a Claude
|
||||
finding or design before acting on it. (`verify`)
|
||||
- **Second-model code review** — agy reads and critiques a file, a set, or a diff
|
||||
independently of Claude. (`review`, `review-files`, `review-diff`)
|
||||
- **Diverse drafts / second opinion** — alternative phrasing or approach. (`text`)
|
||||
- **Live web facts** — current info past Claude's cutoff, with source URLs. (`search`)
|
||||
|
||||
AGY and [GROK](../grok/SKILL.md) are sibling second-opinion routers. Pick one, or
|
||||
run both and compare — disagreement between them is a strong signal to slow down.
|
||||
AGY and [GROK](../grok/SKILL.md) are sibling second-opinion routers. Pick one, or run
|
||||
both and compare — disagreement between them is a strong signal to slow down.
|
||||
|
||||
## When NOT to
|
||||
|
||||
- Pure classify / extract / summarize → cheaper via Tier-0 Ollama (`.claude/OLLAMA.md`).
|
||||
- Editing this repo's code → Claude's own agents own the codebase work. Gemini's
|
||||
`review*` modes are read-only (`--approval-mode plan`) by design; do not give
|
||||
Gemini write access to this repo.
|
||||
- Image / video **generation** → that's GROK's lane (`grok image` / `grok video`),
|
||||
not Gemini here (nano-banana needs an API key — deferred). Gemini CAN analyze an
|
||||
image you give it (`image-analyze`, vision input on OAuth).
|
||||
- **Never** delegate unsupervised destructive / production actions to Gemini.
|
||||
Always review Gemini output before acting on it — like Grok, it can over-claim.
|
||||
- Editing this repo's code → Claude's own agents own the codebase work. The wrapper
|
||||
runs agy read-only in intent (review prompts say "do not modify anything"); do not
|
||||
point it at write tasks in this repo.
|
||||
- Image / video **generation** → GROK's lane. AGY vision is read/analyze only.
|
||||
- **Never** delegate unsupervised destructive / production actions to agy. Always
|
||||
review its output before acting — like Grok, it can over-claim.
|
||||
|
||||
## Safety / operational notes
|
||||
|
||||
- `--skip-trust` is REQUIRED for headless runs (the CWD isn't a Gemini "trusted
|
||||
folder"). Equivalent env: `GEMINI_CLI_TRUST_WORKSPACE=true`. The wrapper passes it.
|
||||
- `review*` runs under `--approval-mode plan` (read-only): Gemini can read files
|
||||
but cannot modify anything. Do not change this to `auto_edit`/`yolo`.
|
||||
- Gemini's `read_file` honors `.gitignore` **and** a workspace sandbox (only files
|
||||
inside the workspace are readable). The wrapper sidesteps both by copying each
|
||||
review target into a temp dir added via `--include-directories` — so review
|
||||
works for tracked, gitignored, and spaced-path files alike.
|
||||
- Prompts are passed via `-p "$(cat <prompt-file>)"` built from a temp file, not
|
||||
inline shell args (avoids quote hell with long/structured content).
|
||||
- stdin is always closed (`</dev/null`) so `-p` never hangs waiting on stdin.
|
||||
- stdout carries two cosmetic warning lines ("True color (24-bit) support not
|
||||
detected", "Ripgrep is not available...") before output; JSON extraction from
|
||||
the first `{` ignores them. A transient `429 No capacity` backoff may appear on
|
||||
**stderr** and self-recovers — it does not affect the parsed answer.
|
||||
- `--dangerously-skip-permissions` is passed so print mode never HANGS on a tool
|
||||
approval prompt (headless). It only lets agy run its own read/search tools inside
|
||||
its sandbox to answer — the wrapper never asks it to write to this repo.
|
||||
- The prompt is passed as the VALUE of `-p` and MUST be last on the command line;
|
||||
all other flags (`--model`, `--add-dir`, `--print-timeout`) come before it.
|
||||
- Prompts are built in a temp file and read with `-p "$(cat <file>)"` (avoids
|
||||
quote hell); stdin is closed (`</dev/null`) so `-p` never waits on stdin.
|
||||
- Output is plain text/markdown. Tool-using turns (review/search/vision) may emit a
|
||||
line or two of tool narration before the answer; the prompts ask agy to suppress
|
||||
it, and residual narration is harmless.
|
||||
|
||||
## Reference
|
||||
- Binary: npm global `gemini` (`C:/Users/guru/AppData/Roaming/npm/gemini` on the
|
||||
host; the npm global dir is on PATH). The wrapper auto-locates it or honors `GEMINI=`.
|
||||
- Version 0.45.2. Auth: Google OAuth (`~/.gemini/oauth_creds.json`), no API key.
|
||||
- Headless contract: `gemini -p "<prompt>" -o json --skip-trust </dev/null` →
|
||||
`{session_id, response, stats}`; answer is `.response`.
|
||||
- Binary: native Go `agy` (`C:/Users/guru/AppData/Local/agy/bin/agy`; on PATH). The
|
||||
wrapper auto-locates it, honors `AGY=`, or reads `identity.json` `agy.binary`.
|
||||
- Version 1.0.16. Auth: Antigravity login (`~/.gemini/antigravity-cli/`), no API key,
|
||||
no `GOOGLE_CLOUD_PROJECT`.
|
||||
- Headless contract: `agy [--model "<name>"] [--add-dir <dir>] --dangerously-skip-permissions -p "<prompt>"` → plain-text answer on stdout (no JSON).
|
||||
- Migration: replaced the `gemini` npm CLI 2026-07-02 (that CLI hit
|
||||
`throwIneligibleOrProjectIdError`). `ask-gemini.sh` is a deprecated shim → `ask-agy.sh`.
|
||||
- Sibling router: [`grok`](../grok/SKILL.md) (image/video/live-data + second opinion).
|
||||
|
||||
280
.claude/skills/agy/scripts/ask-agy.sh
Normal file
280
.claude/skills/agy/scripts/ask-agy.sh
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env bash
|
||||
# ask-agy.sh — Claude -> Google Antigravity CLI (`agy`) router (independent second model).
|
||||
#
|
||||
# Sibling of ask-grok.sh. Routes a task to the Antigravity CLI (`agy`, a native Go
|
||||
# binary at ~/AppData/Local/agy/bin/agy) for an independent, different-vendor second
|
||||
# opinion, verification, code review, vision, or live web search. Multi-model backend
|
||||
# (Gemini 3.5 Flash / Gemini 3.1 Pro / Claude / GPT-OSS) selectable via --model.
|
||||
#
|
||||
# REPLACES the old `gemini` npm CLI (ask-gemini.sh), which broke on this account with
|
||||
# a Cloud-project eligibility error (throwIneligibleOrProjectIdError). `agy` uses
|
||||
# Antigravity's own auth (~/.gemini/antigravity-cli/), no GOOGLE_CLOUD_PROJECT needed.
|
||||
#
|
||||
# Authoritative flag reference (from `agy --help`, Go flag package — 2026-07-02, v1.0.16):
|
||||
# -p | --print | --prompt Run ONE prompt non-interactively and print the response.
|
||||
# STRING flag: the prompt is its VALUE. MUST be LAST on the
|
||||
# line (a bare `-p --model` makes "--model" the prompt).
|
||||
# --model "<name>" Model for the session. Friendly names from `agy models`,
|
||||
# e.g. "Gemini 3.1 Pro (High)", "Gemini 3.5 Flash (Medium)",
|
||||
# "Claude Opus 4.6 (Thinking)". Omit -> CLI default (Flash).
|
||||
# --add-dir <dir> Add a directory to the workspace (repeatable). Needed so
|
||||
# the agent's file tools can read a review/vision target.
|
||||
# --dangerously-skip-permissions Auto-approve tool calls (else print mode can HANG
|
||||
# on an approval prompt until --print-timeout). Headless-safe.
|
||||
# --print-timeout <dur> Print-mode wait timeout (default 5m0s). We also wrap in the
|
||||
# shell `timeout` as a hard belt-and-suspenders bound.
|
||||
# (There is NO JSON/structured-output flag — stdout is plain text/markdown.)
|
||||
#
|
||||
# Output contract: plain text on stdout. Tool-using turns (review/search/vision) may
|
||||
# emit a line or two of tool narration ("I will view the content of ...") before the
|
||||
# answer; we ask the model to suppress it but tolerate residual. No JSON parsing.
|
||||
#
|
||||
# Usage (unchanged from the old skill so callers/SKILL.md stay valid):
|
||||
# ask-agy.sh text "<prompt>" # one-shot answer
|
||||
# ask-agy.sh text --prompt-file <path> # long content
|
||||
# ask-agy.sh verify "<claim or finding to refute>" # adversarial check
|
||||
# ask-agy.sh verify --prompt-file <path>
|
||||
# ask-agy.sh review <file> [instructions] # agy reads + reviews one file
|
||||
# ask-agy.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET together
|
||||
# ask-agy.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
||||
# ask-agy.sh image-analyze <image-path> ["question"] # vision: read image + describe
|
||||
# ask-agy.sh search "<query>" # live web search + sources
|
||||
# ask-agy.sh raw <agy args...> # escape hatch
|
||||
#
|
||||
# Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 agy not found.
|
||||
set -uo pipefail
|
||||
SELF="ask-agy"
|
||||
|
||||
# --- path conversion: native-Windows path for agy args (no-op off Windows) ---
|
||||
# agy is a native Windows binary; Git Bash hands it POSIX paths it cannot resolve.
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
||||
else
|
||||
winpath() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
# --- identity.json (per-machine, gitignored): is agy installed here? ---
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||
PYBIN="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
||||
IDFILE=""
|
||||
[ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||
[ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json"
|
||||
idagy() { # read field $1 from identity.json .agy (fallback .gemini), empty if absent
|
||||
[ -f "$IDFILE" ] || { echo ""; return; }
|
||||
[ -n "$PYBIN" ] || { echo ""; return; }
|
||||
"$PYBIN" -c "import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin); g=(d.get('agy') or d.get('gemini') or {}); v=g.get('$1','')
|
||||
print('' if v is None else (str(v).lower() if isinstance(v,bool) else v))
|
||||
except Exception: print('')" < "$IDFILE"
|
||||
}
|
||||
|
||||
if [ "$(idagy installed)" = "false" ]; then
|
||||
echo "[$SELF] agy is not installed on this machine (identity.json agy.installed=false)." >&2
|
||||
echo "[$SELF] Antigravity CLI runs only on the fleet host. Route this request there, or install agy + set identity.json agy.installed=true." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- locate the agy binary: AGY env > identity.json agy.binary > PATH > known paths ---
|
||||
AGY="${AGY:-}"
|
||||
if [ -n "$AGY" ] && [ ! -x "$AGY" ] && ! command -v "$AGY" >/dev/null 2>&1; then
|
||||
echo "[$SELF] AGY='$AGY' is not an executable agy binary." >&2; exit 127
|
||||
fi
|
||||
cand="$(idagy binary)"
|
||||
[ -z "$AGY" ] && [ -n "$cand" ] && [ -x "$cand" ] && AGY="$cand"
|
||||
if [ -z "$AGY" ]; then
|
||||
if command -v agy >/dev/null 2>&1; then AGY="$(command -v agy)"; else
|
||||
for c in "/c/Users/${USERNAME:-${USER:-x}}/AppData/Local/agy/bin/agy" \
|
||||
"$HOME/AppData/Local/agy/bin/agy" "$LOCALAPPDATA/agy/bin/agy" \
|
||||
"/usr/local/bin/agy" "$HOME/.local/bin/agy"; do
|
||||
[ -n "$c" ] && [ -x "$c" ] && { AGY="$c"; break; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
[ -z "$AGY" ] && { echo "[$SELF] agy CLI not found (set identity.json agy.binary, AGY=, or install the Antigravity CLI)" >&2; exit 127; }
|
||||
|
||||
# Strong model for verify/review*/vision/search; text uses the CLI default (Flash).
|
||||
STRONG_MODEL="${AGY_MODEL:-Gemini 3.1 Pro (High)}"
|
||||
|
||||
MODE="${1:-}"; shift 2>/dev/null || true
|
||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt"
|
||||
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
||||
_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; }
|
||||
|
||||
TIMEOUT_CMD="timeout"
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
||||
fi
|
||||
|
||||
# run agy headless. $1 = hard timeout secs; remaining args = flags placed BEFORE -p.
|
||||
# The prompt (from $PF) is always the LAST token, as the value of -p. agy has no JSON
|
||||
# mode, so stdout is the answer (plain text); stderr kept separate for diagnostics.
|
||||
LAST_FLAGS=()
|
||||
run_agy() {
|
||||
local to="$1"; shift
|
||||
LAST_FLAGS=("$@")
|
||||
"$TIMEOUT_CMD" "$to" "$AGY" "$@" --print-timeout "${to}s" -p "$(cat "$PF")" \
|
||||
>"$OUT" 2>"$ERR" </dev/null || true
|
||||
}
|
||||
|
||||
# agy occasionally returns an empty turn; retry a couple times with backoff, then fail.
|
||||
emit_or_fail() {
|
||||
local txt tries=0 max="${AGY_MAX_TRIES:-3}"
|
||||
txt="$(sed -e 's/[[:space:]]*$//' "$OUT" | sed -e :a -e '/^\s*$/{$d;N;ba}')"
|
||||
while [ -z "${txt//[[:space:]]/}" ] && [ "$tries" -lt $((max-1)) ] && [ ${#LAST_FLAGS[@]} -ge 0 ]; do
|
||||
tries=$((tries+1))
|
||||
echo "[$SELF] empty response - retry $tries/$((max-1)) (backoff ${tries}x3s)..." >&2
|
||||
sleep $((tries*3))
|
||||
run_agy 240 "${LAST_FLAGS[@]}"
|
||||
txt="$(cat "$OUT")"
|
||||
done
|
||||
if [ -n "${txt//[[:space:]]/}" ]; then cat "$OUT"; return 0; fi
|
||||
if grep -qiE 'not authenticated|please (log|sign).?in|auth(entication)? (failed|error|required)|token (has )?expired|unauthorized' "$ERR" 2>/dev/null; then
|
||||
echo "[$SELF] agy auth error - run 'agy' interactively once to sign in, then retry." >&2
|
||||
_logerr "agy auth/login failure" --context "mode=$MODE"
|
||||
else
|
||||
echo "[$SELF] no response from agy after $max attempts. stderr tail:" >&2
|
||||
tail -3 "$ERR" >&2 2>/dev/null || true
|
||||
_logerr "agy returned no response (empty after $max attempts)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy review/vision targets into an included workspace dir so agy's file tools can
|
||||
# reach them regardless of location/spaces; --add-dir this dir.
|
||||
INCLUDE_DIR="$TMP/inbox"
|
||||
prep_includes() { mkdir -p "$INCLUDE_DIR"; }
|
||||
|
||||
NO_TOOLS='Do not use any tools; answer directly in text.'
|
||||
NO_NARRATE='Do not narrate your tool use or steps; output only the final answer.'
|
||||
|
||||
case "$MODE" in
|
||||
text|verify)
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF $MODE \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&2; exit 2; }
|
||||
if [ "$MODE" = "verify" ]; then
|
||||
printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (correct / partly correct / incorrect) plus specific justification. %s\n\nContent:\n%s' "$NO_TOOLS" "$SRC" > "$PF"
|
||||
run_agy 240 --dangerously-skip-permissions --model "$STRONG_MODEL"
|
||||
else
|
||||
printf '%s\n\n%s' "$NO_TOOLS" "$SRC" > "$PF"
|
||||
run_agy 240 --dangerously-skip-permissions
|
||||
fi
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review|file)
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||
target="$1"
|
||||
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}"
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"; cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
tgt_win="$(winpath "$INCLUDE_DIR/$base")"; inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Read the file at this absolute path, then perform the task and stop. Do not modify anything. %s\nPath: %s\n\nTask: %s' "$NO_NARRATE" "$tgt_win" "$instr" > "$PF"
|
||||
run_agy 300 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-files)
|
||||
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
||||
files=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
*) files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
||||
prep_includes
|
||||
list=""; declare -A seen=()
|
||||
for f in "${files[@]}"; do
|
||||
if [ -f "$f" ]; then r="$f"
|
||||
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
||||
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
||||
base="$(basename "$r")"
|
||||
if [ -n "${seen[$base]:-}" ]; then
|
||||
n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}"
|
||||
fi
|
||||
seen[$base]=1; cp -f "$r" "$INCLUDE_DIR/$base"
|
||||
list+="- $(winpath "$INCLUDE_DIR/$base")
|
||||
"
|
||||
done
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything. %s\n\nFiles:\n%s\nTask: %s' "$NO_NARRATE" "$list" "$instr" > "$PF"
|
||||
run_agy 360 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-diff)
|
||||
gdir="$REPO_ROOT"
|
||||
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
||||
ref=""; pathspec=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
||||
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
||||
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
||||
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
||||
if [ ${#pathspec[@]} -gt 0 ]; then
|
||||
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
else
|
||||
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
fi
|
||||
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
||||
gdir_win="$(winpath "$gdir")"
|
||||
{ printf 'Review the following unified git diff. %s\n%s\nYou may read any changed file for full context (paths are relative to %s; strip a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$NO_NARRATE" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||
run_agy 360 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$gdir_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
image-analyze|image|vision)
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF image-analyze <image-path> [\"question\"]" >&2; exit 2; }
|
||||
target="$1"; question="${2:-Describe exactly what is in this image.}"
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] image not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"; cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
img_win="$(winpath "$INCLUDE_DIR/$base")"; inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Read the image at this absolute path and describe exactly what you see. Report only what is actually present; do not guess or invent content. Then stop. %s\nImage path: %s\n\nQuestion: %s' "$NO_NARRATE" "$img_win" "$question" > "$PF"
|
||||
run_agy 300 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
search|websearch)
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF search \"<query>\" | $SELF search --prompt-file <path>" >&2; exit 2; }
|
||||
printf 'Search the web for current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs. %s\n\nQuery: %s' "$NO_NARRATE" "$SRC" > "$PF"
|
||||
run_agy 240 --dangerously-skip-permissions --model "$STRONG_MODEL"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
raw)
|
||||
"$AGY" "$@"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,388 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ask-gemini.sh — Claude -> Google Gemini CLI router (independent second model).
|
||||
#
|
||||
# Sibling of ask-grok.sh. Routes a task to the official Google Gemini CLI
|
||||
# (`gemini`, npm global) for an independent, different-vendor second opinion,
|
||||
# verification, or a Gemini code review. Headless, safe-by-default, JSON-parsed.
|
||||
#
|
||||
# Auth is Google login (OAuth) — NO API key. Creds: ~/.gemini/oauth_creds.json.
|
||||
# If a call fails with an auth error, run `gemini` interactively once and pick
|
||||
# "Login with Google".
|
||||
#
|
||||
# Output contract (VERIFIED on GURU-5070, gemini 0.45.2):
|
||||
# - Prefer JSON: `gemini -p ... -o json` -> {session_id, response, stats}.
|
||||
# The answer text is `.response`. stdout may carry two cosmetic warning lines
|
||||
# ("True color..." / "Ripgrep is not available...") before the JSON; we extract
|
||||
# the object starting at the FIRST '{' to ignore them. stderr (429 backoff,
|
||||
# warnings) is captured SEPARATELY and never fed to the JSON parser.
|
||||
# - `--skip-trust` is REQUIRED headless (the CWD isn't a trusted folder).
|
||||
# - stdin is always closed (</dev/null) so `-p` never hangs waiting on stdin.
|
||||
#
|
||||
# File reads (review*): Gemini's read_file honors .gitignore AND a workspace
|
||||
# sandbox (only files under the workspace/included dirs are readable). To make
|
||||
# review robust for ANY file (tracked, gitignored, with spaces), we copy each
|
||||
# target into a temp dir and add it to the workspace via --include-directories.
|
||||
# review-diff runs with the repo dir included so changed files read in place.
|
||||
#
|
||||
# Usage:
|
||||
# ask-gemini.sh text "<prompt>" # one-shot answer
|
||||
# ask-gemini.sh text --prompt-file <path> # long content
|
||||
# ask-gemini.sh verify "<claim or finding to refute>" # adversarial check
|
||||
# ask-gemini.sh verify --prompt-file <path>
|
||||
# ask-gemini.sh review <file> [instructions] # gemini reads + reviews one file
|
||||
# ask-gemini.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
||||
# ask-gemini.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
||||
# ask-gemini.sh image-analyze <image-path> ["question"] # vision: read_file image + describe (PRO model)
|
||||
# ask-gemini.sh search "<query>" # Google-grounded live web search + sources
|
||||
# ask-gemini.sh raw <gemini args...> # escape hatch
|
||||
#
|
||||
# Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 gemini/python not found.
|
||||
set -uo pipefail
|
||||
SELF="ask-gemini"
|
||||
|
||||
PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
||||
[ -z "$PY" ] && { echo "[$SELF] python (py/python/python3) required for JSON parsing" >&2; exit 127; }
|
||||
|
||||
# --- path conversion: native-Windows path for the gemini args (no-op off Windows) ---
|
||||
# gemini is a native Windows binary (npm shim -> node.exe); Git Bash hands it POSIX
|
||||
# paths (/tmp, /c/.., /d/..) it cannot resolve. cygpath -w converts to C:\... on
|
||||
# MSYS/Cygwin; on Linux/macOS it passes through unchanged. Explicit conversion
|
||||
# removes reliance on MSYS auto-conversion (which breaks on spaces/edge cases).
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
||||
else
|
||||
winpath() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
# --- identity.json (per-machine, gitignored) declares whether gemini is installed here ---
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||
IDFILE=""
|
||||
[ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||
[ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json"
|
||||
idgem() { # read field $1 from identity.json .gemini (empty if absent)
|
||||
[ -f "$IDFILE" ] || { echo ""; return; }
|
||||
"$PY" -c "import json,sys
|
||||
try:
|
||||
g=(json.load(sys.stdin).get('gemini') or {}); v=g.get('$1','')
|
||||
print('' if v is None else (str(v).lower() if isinstance(v,bool) else v))
|
||||
except Exception: print('')" < "$IDFILE"
|
||||
}
|
||||
|
||||
# If identity explicitly says gemini is NOT installed here, fail fast with guidance.
|
||||
if [ "$(idgem installed)" = "false" ]; then
|
||||
echo "[$SELF] gemini is not installed on this machine (identity.json gemini.installed=false)." >&2
|
||||
echo "[$SELF] Gemini runs only on the fleet host. Route this request there, or install the gemini CLI (npm i -g @google/gemini-cli) + set identity.json gemini.installed=true." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- locate the gemini binary: GEMINI env > identity.json gemini.binary > auto-locate ---
|
||||
# An explicit GEMINI= override that isn't runnable is a user error -> fail clearly up front
|
||||
# (covers absolute paths AND a bare name resolvable on PATH, e.g. GEMINI=gemini).
|
||||
GEMINI="${GEMINI:-}"
|
||||
if [ -n "$GEMINI" ] && [ ! -x "$GEMINI" ] && ! command -v "$GEMINI" >/dev/null 2>&1; then
|
||||
echo "[$SELF] GEMINI='$GEMINI' is not an executable gemini binary." >&2; exit 127
|
||||
fi
|
||||
cand="$(idgem binary)"
|
||||
[ -z "$GEMINI" ] && [ -n "$cand" ] && [ -x "$cand" ] && GEMINI="$cand"
|
||||
if [ -z "$GEMINI" ]; then
|
||||
if command -v gemini >/dev/null 2>&1; then GEMINI="$(command -v gemini)"; else
|
||||
for c in "${APPDATA:-}/npm/gemini" "/c/Users/${USERNAME:-${USER:-x}}/AppData/Roaming/npm/gemini" \
|
||||
"$HOME/AppData/Roaming/npm/gemini" "/usr/local/bin/gemini" "$HOME/.npm-global/bin/gemini"; do
|
||||
[ -n "$c" ] && [ -x "$c" ] && { GEMINI="$c"; break; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
[ -z "$GEMINI" ] && { echo "[$SELF] gemini CLI not found (set identity.json gemini.binary, GEMINI=, or install: npm i -g @google/gemini-cli)" >&2; exit 127; }
|
||||
|
||||
# Model: default routing for text; a strong pinned model for verify/review.
|
||||
# gemini-3.1-pro-preview verified available on this account (2026-06-05); overridable.
|
||||
STRONG_MODEL="${GEMINI_MODEL:-gemini-3.1-pro-preview}"
|
||||
|
||||
MODE="${1:-}"; shift 2>/dev/null || true
|
||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt"
|
||||
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
||||
# Functional-error logger (skill name "agy"); soft-fails, never breaks the caller.
|
||||
_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; }
|
||||
|
||||
# gtimeout on macOS (brew coreutils), timeout elsewhere.
|
||||
TIMEOUT_CMD="timeout"
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
||||
fi
|
||||
|
||||
# run gemini headless reading the prompt file. $1=timeout secs; rest=extra flags.
|
||||
# stdout -> $OUT, stderr -> $ERR (kept separate so warning/429 noise never reaches
|
||||
# the JSON parser). Never fail the script on gemini's exit code; we judge by output.
|
||||
# Records the invocation so emit_or_fail can replay it once on a transient empty turn.
|
||||
LAST_RUN=()
|
||||
run_gemini() {
|
||||
local to="$1"; shift
|
||||
LAST_RUN=("$to" "$@")
|
||||
"$TIMEOUT_CMD" "$to" "$GEMINI" -p "$(cat "$PF")" -o json --skip-trust "$@" \
|
||||
>"$OUT" 2>"$ERR" </dev/null || true
|
||||
}
|
||||
|
||||
# extract .response from the JSON object starting at the first '{' in $OUT.
|
||||
# Parsed via stdin so Windows python never resolves a git-bash (/c/...) path.
|
||||
#
|
||||
# Some pinned-pro tool-using turns (notably image-analyze) leak the model's
|
||||
# internal reasoning stream into .response: a stray token + a 'thought' marker
|
||||
# followed by 'CRITICAL INSTRUCTION N:' lines, then the real answer. We strip
|
||||
# that preamble ONLY when the signature is clearly present, so clean responses
|
||||
# (text/verify/review/search) pass through byte-for-byte unchanged.
|
||||
gresponse() { "$PY" -c "import json,sys,re,os
|
||||
raw=sys.stdin.read()
|
||||
i=raw.find('{')
|
||||
if i < 0:
|
||||
print(''); sys.exit(0)
|
||||
try:
|
||||
r=json.loads(raw[i:]).get('response','') or ''
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
head=r[:40].lower()
|
||||
leak=('thought' in head) or ('critical instruction' in r.lower()[:600])
|
||||
if leak:
|
||||
lines=r.split('\n')
|
||||
keep=[]; dropping=True
|
||||
for ln in lines:
|
||||
s=ln.strip()
|
||||
low=s.lower()
|
||||
if dropping and (
|
||||
low.endswith('thought') or low.startswith('critical instruction')
|
||||
or low.startswith('thought:') or low=='' ):
|
||||
continue
|
||||
dropping=False
|
||||
keep.append(ln)
|
||||
cleaned='\n'.join(keep).strip()
|
||||
r=cleaned if cleaned else r.strip()
|
||||
# AGY_CLEAN: aggressive prefix scrub for tool-using turns (image-analyze), which
|
||||
# can fuse a stray stream/tool token onto the front of the answer (e.g. '.',
|
||||
# '.94>', 'uem_image_0_0_png}'). Off by default so text/verify/review/search are
|
||||
# byte-exact. We only remove a junk run that ends in a stream delimiter (} > :)
|
||||
# or a lone leading punctuation char, immediately before the first real sentence.
|
||||
if os.environ.get('AGY_CLEAN') == '1' and r:
|
||||
# The pro-preview tool loop sometimes prepends a numbered/markdown reasoning
|
||||
# block before the actual answer. If a clear answer pivot follows such a
|
||||
# preamble, keep from the pivot onward (the user-facing answer).
|
||||
if re.search(r'(?im)^\s*\d+[.)]\s', r) or 'thought' in r[:60].lower():
|
||||
pivs=list(re.finditer(r'(?i)(Based on the image\b|\*\*Answer:?\*\*|The image (?:contains|shows|displays)\b)', r))
|
||||
if pivs:
|
||||
r=r[pivs[-1].start():]
|
||||
m=re.match(r'^[^\n]{0,40}?(?:\.png\)|\.jpe?g\)|[}>:)])\s*([\"A-Z].*)$', r, re.S)
|
||||
if m and m.group(1):
|
||||
r=m.group(1)
|
||||
else:
|
||||
# a short leading junk run (ASCII punctuation/digits or non-Latin stream
|
||||
# tokens) before a capitalized/quoted sentence start. Bounded length so we
|
||||
# never eat a real lowercase sentence or real prose.
|
||||
m=re.match(r'^(?:[^A-Za-z\"]|[^\x00-\x7f]){1,8}([A-Z\"].*)$', r, re.S)
|
||||
if m and m.group(1):
|
||||
r=m.group(1)
|
||||
r=r.strip()
|
||||
print(r)" < "$OUT"; }
|
||||
|
||||
# detect a GENUINE auth failure in stderr (precise remediation hint). Tightened 2026-06-17 -
|
||||
# the old broad regex (bare login|credential|authenticat|oauth|401) matched benign mid-run
|
||||
# token-refresh lines and false-flagged working sessions as auth failures.
|
||||
auth_failed() { grep -qiE 'invalid_grant|unauthorized|not authenticated|authentication failed|re-?authenticat|please (log|sign).?in|login with google|token (has )?expired|no (valid )?credentials' "$ERR" 2>/dev/null; }
|
||||
# detect a quota / rate-capacity exhaustion (the pinned strong model can be capped mid-session)
|
||||
quota_exhausted() { grep -qiE 'exhausted your capacity|quota|resource[_ ]?exhausted|rate limit|too many requests|429' "$ERR" 2>/dev/null; }
|
||||
|
||||
emit_or_fail() { # print .response; gemini intermittently returns an empty turn, so retry a few
|
||||
# times with backoff before giving up (single retry was insufficient - 2 empties
|
||||
# in a row caused spurious failures during live research, 2026-06-17).
|
||||
# Do ALL retries first; only classify the failure (auth vs generic) AFTER exhausting them.
|
||||
# (Checking auth_failed INSIDE the loop caused false aborts: a benign mid-run credential-refresh
|
||||
# line in stderr matched the auth regex and killed the retries even though auth was fine. 2026-06-17.)
|
||||
local txt tries=0 max="${AGY_MAX_TRIES:-3}"
|
||||
txt="$(gresponse)"
|
||||
while [ -z "$txt" ] && [ "$tries" -lt $((max-1)) ] && [ ${#LAST_RUN[@]} -gt 0 ]; do
|
||||
tries=$((tries+1))
|
||||
echo "[$SELF] empty response - retry $tries/$((max-1)) (backoff ${tries}x3s)..." >&2
|
||||
sleep $((tries*3)) # 3s, 6s backoff (covers transient empties / 429s / token refresh)
|
||||
run_gemini "${LAST_RUN[@]}"
|
||||
txt="$(gresponse)"
|
||||
done
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
||||
# Quota fallback: if the pinned strong model is capacity/quota-capped, retry ONCE on the default
|
||||
# (lighter) model by stripping -m from the last invocation - the default model has a separate quota.
|
||||
if quota_exhausted && [ ${#LAST_RUN[@]} -gt 0 ]; then
|
||||
echo "[$SELF] '$STRONG_MODEL' quota exhausted - retrying once on the default (lighter) model..." >&2
|
||||
local nr=() a skip=0
|
||||
for a in "${LAST_RUN[@]}"; do
|
||||
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
||||
if [ "$a" = "-m" ]; then skip=1; continue; fi
|
||||
nr+=("$a")
|
||||
done
|
||||
run_gemini "${nr[@]}"
|
||||
txt="$(gresponse)"
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
||||
fi
|
||||
if auth_failed; then
|
||||
echo "[$SELF] Gemini auth error - run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
||||
_logerr "gemini auth/login failure" --context "mode=$MODE"
|
||||
else
|
||||
echo "[$SELF] no response from gemini after $max attempts. stderr tail:" >&2
|
||||
tail -3 "$ERR" >&2 2>/dev/null || true
|
||||
_logerr "gemini returned no response (empty after $max attempts)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy target files into an included temp workspace dir so gemini's read_file can
|
||||
# reach them regardless of .gitignore / workspace sandbox. Echoes the included dir.
|
||||
INCLUDE_DIR="$TMP/inbox"
|
||||
prep_includes() { mkdir -p "$INCLUDE_DIR"; }
|
||||
|
||||
case "$MODE" in
|
||||
text|verify)
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF $MODE \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&2; exit 2; }
|
||||
if [ "$MODE" = "verify" ]; then
|
||||
printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (e.g. correct / partly correct / incorrect) plus specific justification. Answer in text only; do not use any tools.\n\nContent:\n%s' "$SRC" > "$PF"
|
||||
run_gemini 180 -m "$STRONG_MODEL"
|
||||
else
|
||||
printf 'Answer the following directly in text. Do not use any tools.\n\n%s' "$SRC" > "$PF"
|
||||
run_gemini 180
|
||||
fi
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review|file)
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||
target="$1"
|
||||
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}"
|
||||
# GOTCHA: a relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
||||
# NOT a submodule/subdir. "server/src/x.rs" relative to a submodule fails ("file not found")
|
||||
# unless CWD is that submodule. Pass ABSOLUTE paths for submodule/subtree files.
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"
|
||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
tgt_win="$(winpath "$INCLUDE_DIR/$base")"
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Use your read_file tool to read the file at this absolute path, then perform the task and stop. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF"
|
||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-files)
|
||||
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
||||
files=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
*) files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
||||
prep_includes
|
||||
list=""
|
||||
declare -A seen=()
|
||||
# GOTCHA: each relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
||||
# NOT a submodule/subdir. Paths relative to a submodule fail unless CWD is that submodule.
|
||||
# Pass ABSOLUTE paths for submodule/subtree files (e.g. build the list with `find "$(pwd)/..."`).
|
||||
for f in "${files[@]}"; do
|
||||
if [ -f "$f" ]; then r="$f"
|
||||
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
||||
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
||||
base="$(basename "$r")"
|
||||
# de-collide identical basenames from different dirs
|
||||
if [ -n "${seen[$base]:-}" ]; then
|
||||
n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}"
|
||||
fi
|
||||
seen[$base]=1
|
||||
cp -f "$r" "$INCLUDE_DIR/$base"
|
||||
list+="- $(winpath "$INCLUDE_DIR/$base")
|
||||
"
|
||||
done
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Use your read_file tool to read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything.\n\nFiles:\n%s\nTask: %s' "$list" "$instr" > "$PF"
|
||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-diff)
|
||||
gdir="$REPO_ROOT"
|
||||
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
||||
ref=""; pathspec=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
||||
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
||||
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
||||
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
||||
if [ ${#pathspec[@]} -gt 0 ]; then
|
||||
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
else
|
||||
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
fi
|
||||
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
||||
gdir_win="$(winpath "$gdir")"
|
||||
{ printf 'Review the following unified git diff. %s\nYou may use your read_file tool on any changed file for full context (paths in the diff are relative to %s; strip the a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$gdir_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
image-analyze|image|vision)
|
||||
# Independent second-model VISION. The default flash-lite router hallucinates
|
||||
# image content, so we PIN the pro vision model (STRONG_MODEL) and run with
|
||||
# yolo approval so read_file can execute. The image is copied into an included
|
||||
# temp dir (like the review modes) and handed to Gemini by absolute winpath.
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF image-analyze <image-path> [\"question\"]" >&2; exit 2; }
|
||||
target="$1"
|
||||
question="${2:-Describe exactly what is in this image.}"
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] image not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"
|
||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
img_win="$(winpath "$INCLUDE_DIR/$base")"
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
# Image path goes in via %s (never as a printf format string).
|
||||
printf 'Use your read_file tool to read the image at this absolute path, then describe exactly what you see. Report only what is actually present in the image; do not guess or invent content. Then stop. Do not modify anything.\nImage path: %s\n\nQuestion: %s' "$img_win" "$question" > "$PF"
|
||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode yolo --include-directories "$inc_win"
|
||||
AGY_CLEAN=1 emit_or_fail
|
||||
;;
|
||||
|
||||
search|websearch)
|
||||
# Google-grounded LIVE web search (mirrors grok xsearch). Gemini's
|
||||
# google_web_search tool works on OAuth; run with yolo so the tool can fire.
|
||||
# Query goes via the prompt file so long queries don't hit shell-quote limits.
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF search \"<query>\" | $SELF search --prompt-file <path>" >&2; exit 2; }
|
||||
printf 'Use your google_web_search tool to find current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs.\n\nQuery: %s' "$SRC" > "$PF"
|
||||
run_gemini 180 -m "$STRONG_MODEL" --approval-mode yolo
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
raw)
|
||||
"$GEMINI" "$@"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;;
|
||||
esac
|
||||
# ask-gemini.sh — DEPRECATED shim. The AGY skill now routes to the Antigravity CLI
|
||||
# (`agy`), not the old Google `gemini` npm CLI (which broke on this account with a
|
||||
# Cloud-project eligibility error). This shim forwards to ask-agy.sh so any existing
|
||||
# references keep working. New callers should use ask-agy.sh directly.
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||
exec bash "$DIR/ask-agy.sh" "$@"
|
||||
|
||||
120
.claude/skills/alis/SKILL.md
Normal file
120
.claude/skills/alis/SKILL.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: alis
|
||||
description: "Build the ALIS (Medtelligent) staff bulk-import .xls (13-column template) for Cascades of Tucson (communityId 622) and read the live staff roster via the read-only API. Triggers: alis, alisonline, medtelligent, alis staff import, add staff to alis, alis security roles."
|
||||
---
|
||||
|
||||
# ALIS Skill (Medtelligent) — staff import builder + roster reference
|
||||
|
||||
ALIS is Medtelligent's assisted-living EHR. This skill exists to **create/change staff
|
||||
(and their logins) in bulk**, which in ALIS is done by **uploading an .xls in the web UI**
|
||||
(`Staff -> Import`) — there is **no staff-write API**. The skill:
|
||||
|
||||
1. **Reads** the live staff roster via the ALIS API (read-only) to learn how staff are set
|
||||
up — the security-role and job-role vocabulary, and a **job-role → security-role map**.
|
||||
2. **Builds** a correctly-formatted import workbook from your CSV/JSON of new hires,
|
||||
inferring each person's Security Roles from that reference so new staff match existing
|
||||
ones. You upload it in ALIS and fine-tune there.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
```bash
|
||||
PY="bash $CLAUDETOOLS_ROOT/.claude/scripts/py.sh"
|
||||
ALIS="$PY C:/claudetools/.claude/skills/alis/scripts/alis.py"
|
||||
|
||||
# --- read (reference) ---
|
||||
$ALIS auth-test # mint token, confirm scope
|
||||
$ALIS communities # communityId(s) in scope (Cascades = 622)
|
||||
$ALIS staff --status Hired --limit 20 # roster
|
||||
$ALIS roles # live security + job role vocabulary
|
||||
$ALIS role-map [--refresh] # jobRole -> securityRole(s), learned from Hired staff
|
||||
|
||||
# --- build (the deliverable) ---
|
||||
$ALIS template --out new_staff.xls # blank, exactly-formatted template to hand-fill
|
||||
$ALIS build-import --input hires.csv --out import.xls # NEW staff (create format)
|
||||
$ALIS build-import --input hires.csv --out import.xls --gen-passwords # also mint logins
|
||||
$ALIS build-import --input edits.csv --out upd.xls --format update # edit existing (ALIS ID)
|
||||
$ALIS inspect import.xls # dump a workbook to verify before upload
|
||||
```
|
||||
|
||||
Transport auto-selects httpx if installed, else stdlib urllib. Workbook I/O needs
|
||||
`xlwt` (write .xls) + `xlrd` (read .xls); `openpyxl` for .xlsx.
|
||||
|
||||
## Credentials
|
||||
|
||||
Never hardcoded. The user login (used to mint a JWT) loads from the SOPS vault:
|
||||
|
||||
```
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
|
||||
get-field clients/cascades-tucson/alis-api-howard-user.sops.yaml credentials.username
|
||||
```
|
||||
|
||||
**Auth model (critical, verified live 2026-06-29):** `POST /user/tokens` with
|
||||
`{username, password}` returns a JWT (`accessToken`, ~1h) + `refreshToken`; send
|
||||
`Authorization: Bearer <accessToken>`. The **username must be tenant-qualified** —
|
||||
`howard.enos@cascadestucson`, not bare `howard.enos` (bare returns HTTP 400). Env overrides
|
||||
for testing: `ALIS_USERNAME`, `ALIS_PASSWORD`, `ALIS_BASE_URL`, `ALIS_COMMUNITY_ID`.
|
||||
|
||||
## The import template (what ALIS expects)
|
||||
|
||||
13 columns, exact order — Sheet1 header, then one row per staff. Sheet2 carries the
|
||||
dropdown lists. The builder reproduces both exactly.
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| First Name, Last Name | required |
|
||||
| Staff Record Number | your HR id / match key |
|
||||
| Security Roles | inferred from Job Role if blank (see role-map) |
|
||||
| Staff Status | Applicant / **Hired** / Discharged / Rejected (default Hired) |
|
||||
| Hire Date | date — format unconfirmed, builder passes your string through |
|
||||
| Login Enabled | Yes / No (auto: Yes if Email+Password present, else No) |
|
||||
| Email | required if Login Enabled = Yes |
|
||||
| Password | credential — see below |
|
||||
| Date of Birth, Gender | Gender: Female / Male |
|
||||
| Job Role | free text; drives Security Role inference |
|
||||
| Cell Phone | |
|
||||
|
||||
Input headers are matched flexibly (`Job Title`→Job Role, `Cell`/`Mobile`→Cell Phone,
|
||||
`DOB`→Date of Birth, `Login`→Login Enabled, etc.).
|
||||
|
||||
## Passwords (handle with care)
|
||||
|
||||
- By default the builder does **not** invent passwords — supply them, or set
|
||||
`Login Enabled = No`.
|
||||
- `--gen-passwords` mints a strong password for each Login-enabled row missing one and writes
|
||||
them to a **plaintext sidecar `*_passwords.csv`** next to the .xls. That file is a credential
|
||||
set: distribute to staff, then **delete it or vault it**. Never commit it. The terminal
|
||||
output never prints passwords.
|
||||
|
||||
## Two import formats — create vs update (confirmed from the real export)
|
||||
|
||||
ALIS has two layouts; the builder writes either via `--format`:
|
||||
|
||||
- **create** (default, new staff): columns include **Password**, no ALIS ID. Rows **without**
|
||||
an ALIS ID are **created** as new staff.
|
||||
- **update** (existing staff): leads with **ALIS ID** (the staffId), no Password. Rows **with**
|
||||
an ALIS ID **update** the matching staff member. (This is the format ALIS exports as
|
||||
`ALIS_Staff_Update_Import.xls`.)
|
||||
|
||||
So the create-vs-update match key is **ALIS ID** — present = update, absent = create.
|
||||
**Dates are `MM/DD/YYYY`** (confirmed). Security Roles are **comma-separated** and the builder
|
||||
infers the *full typical combination* for a job role (e.g. `Med Tech` →
|
||||
`Caregiver (Cascades), Medication Tech`), learned from current staff in `role-map.json`.
|
||||
|
||||
Still smart to **test with ONE row first** before a bulk run. Scope: this credential only sees
|
||||
Cascades of Tucson (communityId 622).
|
||||
|
||||
## Scope reality
|
||||
|
||||
The ALIS API has **only GET endpoints for staff** — no create/update/delete. Do not try to
|
||||
"PUT a staff record"; it does not exist. The .xls upload is the supported write path. (The
|
||||
API *does* have writes for residents/prospects/billing, out of scope for this skill.)
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/alis_client.py` — API client (JWT auth via vault, staff/community reads, role-map).
|
||||
- `scripts/import_builder.py` — template spec, input parsing, validation, role inference,
|
||||
password gen, .xls read/write.
|
||||
- `scripts/alis.py` — CLI.
|
||||
- `references/api-reference.md` — endpoints, auth, template, role vocabulary.
|
||||
- `references/role-map.json` — cached job-role → security-role reference (refresh with
|
||||
`role-map --refresh`).
|
||||
BIN
.claude/skills/alis/references/ALIS_Staff_Import.template.xls
Normal file
BIN
.claude/skills/alis/references/ALIS_Staff_Import.template.xls
Normal file
Binary file not shown.
75
.claude/skills/alis/references/api-reference.md
Normal file
75
.claude/skills/alis/references/api-reference.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# ALIS API Reference (for the `alis` skill)
|
||||
|
||||
Source of truth: the live Swagger specs the ALIS API publishes.
|
||||
- Swagger UI: https://api.alisonline.com/index.html
|
||||
- Raw specs: `https://api.alisonline.com/specs/v1/openapi.json` (also `v2`, `v3`)
|
||||
- Vendor docs (gated, requires support login): https://support.alisonline.com/
|
||||
|
||||
ALIS = Medtelligent's assisted-living EHR. The API is a **partner/App-Store integration
|
||||
API**. A tenant lives at `<tenant>.alisonline.com` (e.g. `cascadestucson.alisonline.com`)
|
||||
but **all API traffic goes to the shared host `api.alisonline.com`**, scoped by the
|
||||
logged-in user's company + a `communityId`.
|
||||
|
||||
Spec sizes at build (2026-06-29): v1 = 139 paths (main surface), v2 = 14 (newer streaming
|
||||
exports + incidents), v3 = 5 (streaming exports). Tags: User (auth), Admin, Export:*,
|
||||
Integration:* (Billing/Care/Communities/Residents/Staff/Prospects/Incidents/Hooks/App
|
||||
Specific), Pharmacy.
|
||||
|
||||
## Authentication
|
||||
|
||||
Global security is an OR list: `Bearer | BasicAuth | VendorKey` — any one authorizes.
|
||||
|
||||
- **Bearer (what this skill uses):**
|
||||
- `POST /user/tokens` body `{"username":"<user>@<tenantKey>","password":"..."}`
|
||||
→ `{ accessToken (JWT, expiresIn 3600), refreshToken }`
|
||||
- `POST /user/tokens/refresh` body `{accessToken, refreshToken}` → new pair
|
||||
- Send `Authorization: Bearer <accessToken>` on every call.
|
||||
- **The username MUST be tenant-qualified.** `howard.enos` → HTTP 400
|
||||
(`Username must match ^<username>@<tenantKey>$`); `howard.enos@cascadestucson` → 200.
|
||||
tenantKey = the tenant subdomain (`cascadestucson`).
|
||||
- **VendorKey:** `X-Vendor-Key: <key>` header — issued by Medtelligent when an App-Store app
|
||||
is installed (per-client creds). Not required when a user JWT is used.
|
||||
- **BasicAuth:** `Authorization: Basic ...` — the existing
|
||||
`clients/cascades-tucson/alis-api-microsoft-basic` vault entry is this style (used by
|
||||
Microsoft to call ALIS).
|
||||
|
||||
## Staff endpoints — READ ONLY (this is the key constraint)
|
||||
|
||||
There is **no** POST/PUT/PATCH/DELETE for staff anywhere in v1/v2/v3. All six are GET:
|
||||
|
||||
| Method | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/v1/integration/staff?communityId={id}` | roster (communityId REQUIRED — omitting → 403 "Not authorized for facility 0") |
|
||||
| GET | `/v1/integration/staff/{staffId}` | one staff member |
|
||||
| GET | `/v1/integration/staff/{staffId}/basicInfo` | address, license, jobRole, **securityRoles** |
|
||||
| GET | `/v1/integration/staff/{staffId}/photo` | photo |
|
||||
| GET | `/v1/export/staff` | bulk export |
|
||||
| GET | `/v1/export/staff/complianceDetails` | training/compliance |
|
||||
|
||||
Staff list record fields: `staffId, companyTextKey, communityId, firstName, lastName,
|
||||
nickName, staffRecordNumber, mobilePhoneNumber, primaryEmail, dateOfBirth, status,
|
||||
hireDate, dischargeDate, hasPhoto, jobRole, securityRoles[]`.
|
||||
|
||||
Optional query params on the list: `status`, `includeAssociatedStaff`.
|
||||
|
||||
**To CHANGE staff** → there is no API. Use the ALIS web UI **Staff → Import** with the
|
||||
13-column .xls (see `import_builder.py` / SKILL.md). That import sets `Login Enabled` and
|
||||
`Password`, i.e. it is also how staff *logins* are provisioned.
|
||||
|
||||
## Other write surfaces (out of scope for this skill, but available on the JWT)
|
||||
|
||||
The API *does* allow writes for non-staff objects, e.g.:
|
||||
- Residents: `POST /v1/integration/residents` (create), `POST .../{residentId}/basicInfo`,
|
||||
contacts, photo, observations, vitals, room assignments, diagnoses, monitoring flags.
|
||||
- Prospects (CRM): `POST/PUT /v1/integration/prospects...`
|
||||
- Billing: incidental charges, payments, statements.
|
||||
- Webhooks: `/v1/integration/hooks` (subscribe to object create/modify events).
|
||||
|
||||
## Scope observed (Cascades, communityId 622, build 2026-06-29)
|
||||
|
||||
- 1 community: 622 = "Cascades of Tucson".
|
||||
- 612 staff total (504 Discharged, 107 Hired, 1 Observer).
|
||||
- 23 distinct Security Roles in use; 74 distinct Job Role strings (free text — includes
|
||||
typos/dupes like `caregiver` vs `Certified Caregiver`, and junk like `Test`,
|
||||
`Dead Weight` — treat Job Role as free text, Security Roles as the controlled list).
|
||||
- See `role-map.json` for the snapshot + job-role → security-role mapping.
|
||||
82
.claude/skills/alis/references/role-map.json
Normal file
82
.claude/skills/alis/references/role-map.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"source": "ALIS_Staff_Update_Import.xls (current Hired staff export)",
|
||||
"community": {
|
||||
"id": 622,
|
||||
"name": "Cascades of Tucson"
|
||||
},
|
||||
"rowCount": 101,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"securityRolesAreCommaSeparated": true,
|
||||
"securityRoleVocabulary": [
|
||||
"Activities",
|
||||
"Business Support",
|
||||
"CFO",
|
||||
"Caregiver (Cascades)",
|
||||
"Community Administrator",
|
||||
"Front Desk & Security",
|
||||
"General Director or Manager",
|
||||
"General Line Level",
|
||||
"Health Admin Assistant",
|
||||
"Master Administrator",
|
||||
"Medication Tech",
|
||||
"Nurse (Cascades)",
|
||||
"Pharmacy Tech",
|
||||
"Sales",
|
||||
"Sales - Move In Coordinator",
|
||||
"Sales Manager"
|
||||
],
|
||||
"jobRoleVocabulary": [
|
||||
"Activity Director",
|
||||
"Activity Staff",
|
||||
"Admin Assistant",
|
||||
"Business Office Manager",
|
||||
"CFO",
|
||||
"Certified Caregiver",
|
||||
"Controller",
|
||||
"Cook",
|
||||
"Dining Room Manager",
|
||||
"Dining Staff",
|
||||
"Director of Sales/Marketing",
|
||||
"Housekeeping Director",
|
||||
"Kitchen Supervisor",
|
||||
"Laundry",
|
||||
"Line Cook",
|
||||
"Maintenance Director",
|
||||
"Maintenance Worker",
|
||||
"Med Tech",
|
||||
"Memory Care Reception",
|
||||
"Memory Care Unit Reception",
|
||||
"Office Staff",
|
||||
"Pharmacy Tech",
|
||||
"Resident Caregiver (non-certified)",
|
||||
"Sales Associate",
|
||||
"Test"
|
||||
],
|
||||
"jobRoleToSecurityRolesCombo": {
|
||||
"Activity Director": "General Director or Manager, Activities",
|
||||
"Admin Assistant": "General Director or Manager, Caregiver (Cascades), Health Admin Assistant, Medication Tech",
|
||||
"Certified Caregiver": "Caregiver (Cascades)",
|
||||
"Housekeeping Director": "General Director or Manager",
|
||||
"Kitchen Supervisor": "General Line Level",
|
||||
"Laundry": "General Line Level",
|
||||
"Line Cook": "General Line Level",
|
||||
"Director of Sales/Marketing": "Sales Manager, Sales , Community Administrator",
|
||||
"Memory Care Unit Reception": "Front Desk & Security, General Line Level",
|
||||
"Dining Room Manager": "General Director or Manager",
|
||||
"CFO": "Community Administrator",
|
||||
"Test": "Caregiver (Cascades), Medication Tech",
|
||||
"Dining Staff": "General Line Level",
|
||||
"Cook": "General Line Level",
|
||||
"Sales Associate": "Sales Manager, Sales , Community Administrator",
|
||||
"Med Tech": "Caregiver (Cascades), Medication Tech",
|
||||
"Maintenance Worker": "General Line Level",
|
||||
"Office Staff": "Front Desk & Security, General Director or Manager, Sales - Move In Coordinator",
|
||||
"Maintenance Director": "General Director or Manager",
|
||||
"Controller": "CFO",
|
||||
"Resident Caregiver (non-certified)": "Caregiver (Cascades)",
|
||||
"Memory Care Reception": "Front Desk & Security",
|
||||
"Business Office Manager": "CFO",
|
||||
"Pharmacy Tech": "Pharmacy Tech",
|
||||
"Activity Staff": "Activities"
|
||||
}
|
||||
}
|
||||
289
.claude/skills/alis/scripts/alis.py
Normal file
289
.claude/skills/alis/scripts/alis.py
Normal file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the `alis` skill.
|
||||
|
||||
ALIS (Medtelligent) staff are READ via the API and CHANGED via a web-UI bulk
|
||||
import. This CLI does both halves:
|
||||
- read: auth-test, communities, staff, roles, role-map (reference for setup)
|
||||
- build: template, build-import, inspect (produce/check the import workbook)
|
||||
|
||||
The API is read-only for staff - there is no write subcommand because no staff
|
||||
write endpoint exists. The end deliverable is an .xls you upload in ALIS.
|
||||
|
||||
Examples:
|
||||
python alis.py auth-test
|
||||
python alis.py communities
|
||||
python alis.py staff [--status Hired] [--limit 20] [--json]
|
||||
python alis.py roles # live security/job role vocab
|
||||
python alis.py role-map [--refresh] # jobRole -> securityRole reference
|
||||
python alis.py template --out new_staff.xls # blank, ready-to-fill template
|
||||
python alis.py build-import --input hires.csv --out import.xls [--gen-passwords]
|
||||
python alis.py inspect import.xls
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from alis_client import ALISClient, ALISError
|
||||
import import_builder as ib
|
||||
|
||||
|
||||
# --- errorlog (soft-fail, per CLAUDE.md) --------------------------------------
|
||||
def _log_skill_error(skill: str, msg: str, context: str = "") -> None:
|
||||
try:
|
||||
root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
|
||||
)
|
||||
h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
|
||||
if not os.path.exists(h):
|
||||
return
|
||||
a = ["bash", h, skill, msg]
|
||||
if context:
|
||||
a += ["--context", context]
|
||||
subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 404/"not found" on a probe is expected; a real auth/transport failure is not.
|
||||
_EXPECTED = ("http 404", "not found", "http 429", "too many requests")
|
||||
|
||||
|
||||
def _should_log(msg: str) -> bool:
|
||||
if os.environ.get("ALIS_SUPPRESS_ERRORLOG"):
|
||||
return False
|
||||
m = (msg or "").lower()
|
||||
return not any(x in m for x in _EXPECTED)
|
||||
|
||||
|
||||
def _emit(obj, as_json: bool) -> None:
|
||||
print(json.dumps(obj, indent=2, default=str))
|
||||
|
||||
|
||||
def _trunc(s, n):
|
||||
s = "" if s is None else str(s)
|
||||
return s if len(s) <= n else s[: n - 1] + "…"
|
||||
|
||||
|
||||
# --- read subcommands ---------------------------------------------------------
|
||||
def cmd_auth_test(args) -> int:
|
||||
c = ALISClient()
|
||||
c.authenticate()
|
||||
comms = c.list_communities()
|
||||
print("[OK] authenticated to", c.api_base_url)
|
||||
print("[INFO] communities:",
|
||||
[(x.get("communityId"), x.get("communityName")) for x in comms])
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_communities(args) -> int:
|
||||
c = ALISClient()
|
||||
comms = c.list_communities()
|
||||
if args.json:
|
||||
_emit(comms, True)
|
||||
else:
|
||||
for x in comms:
|
||||
print(f" {x.get('communityId'):>6} {x.get('communityName')}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_staff(args) -> int:
|
||||
c = ALISClient()
|
||||
staff = c.list_staff(community_id=args.community, status=args.status)
|
||||
if args.json:
|
||||
_emit(staff[: args.limit] if args.limit else staff, True)
|
||||
return 0
|
||||
print(f"[INFO] {len(staff)} staff"
|
||||
+ (f" (status={args.status})" if args.status else ""))
|
||||
shown = staff[: args.limit] if args.limit else staff
|
||||
for s in shown:
|
||||
name = f"{s.get('firstName','')} {s.get('lastName','')}".strip()
|
||||
sr = s.get("securityRoles") or []
|
||||
sr = ", ".join(sr) if isinstance(sr, list) else str(sr)
|
||||
print(f" {s.get('staffId'):>7} {_trunc(name,26):26} "
|
||||
f"{_trunc(s.get('status',''),11):11} "
|
||||
f"{_trunc(s.get('jobRole') or '-',26):26} [{_trunc(sr,40)}]")
|
||||
if args.limit and len(staff) > args.limit:
|
||||
print(f" ... {len(staff)-args.limit} more (raise --limit or use --json)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_roles(args) -> int:
|
||||
c = ALISClient()
|
||||
rm = c.build_role_map(community_id=args.community)
|
||||
if args.json:
|
||||
_emit(rm, True)
|
||||
return 0
|
||||
print(f"[INFO] from {rm['sourceStaffCount']} staff "
|
||||
f"({rm['hiredCount']} Hired), community {rm['community']['id']}")
|
||||
print("\n=== Security Roles in use ===")
|
||||
for r in rm["securityRoleVocabulary"]:
|
||||
print(f" {r}")
|
||||
print("\n=== Job Roles in use ===")
|
||||
for r in rm["jobRoleVocabulary"]:
|
||||
print(f" {r}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_role_map(args) -> int:
|
||||
if args.refresh:
|
||||
c = ALISClient()
|
||||
rm = c.build_role_map(community_id=args.community)
|
||||
path = Path(__file__).resolve().parent.parent / "references" / "role-map.json"
|
||||
path.write_text(json.dumps(rm, indent=2), encoding="utf-8")
|
||||
print(f"[OK] refreshed role-map.json from live ({rm['sourceStaffCount']} staff)")
|
||||
else:
|
||||
rm = ib.load_role_map()
|
||||
if not rm:
|
||||
print("[WARNING] no cached role-map.json; run with --refresh", file=sys.stderr)
|
||||
return 1
|
||||
combo = rm.get("jobRoleToSecurityRolesCombo") or {
|
||||
k: ", ".join(v) for k, v in rm.get("jobRoleToSecurityRoles", {}).items()}
|
||||
if args.json:
|
||||
_emit(combo, True)
|
||||
return 0
|
||||
print("=== Job Role -> typical Security Roles (learned from current staff) ===")
|
||||
for jr, sr in sorted(combo.items()):
|
||||
print(f" {_trunc(jr,34):34} -> {sr}")
|
||||
return 0
|
||||
|
||||
|
||||
# --- build subcommands --------------------------------------------------------
|
||||
def cmd_template(args) -> int:
|
||||
out = ib.write_workbook([], args.out)
|
||||
print(f"[OK] blank ALIS staff-import template -> {out}")
|
||||
print("[INFO] columns:", " | ".join(ib.TEMPLATE_HEADERS))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_build_import(args) -> int:
|
||||
rows_in = ib.read_input_rows(args.input)
|
||||
if not rows_in:
|
||||
print("[ERROR] no rows read from input", file=sys.stderr)
|
||||
return 1
|
||||
role_map = ib.load_role_map()
|
||||
if args.refresh_roles:
|
||||
try:
|
||||
role_map = ALISClient().build_role_map(community_id=args.community)
|
||||
except ALISError as exc:
|
||||
print(f"[WARNING] live role refresh failed ({exc}); using cached map",
|
||||
file=sys.stderr)
|
||||
rows, report = ib.enrich_and_validate(
|
||||
rows_in, role_map, default_status=args.default_status,
|
||||
suggest_roles=not args.no_suggest)
|
||||
|
||||
sidecar = None
|
||||
if args.gen_passwords:
|
||||
if args.format == "update":
|
||||
print("[WARNING] --gen-passwords ignored: the update format has no "
|
||||
"Password column. Use --format create for new logins.",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
generated = ib.fill_passwords(rows)
|
||||
sidecar = ib.write_password_sidecar(generated, args.out)
|
||||
|
||||
out = ib.write_workbook(rows, args.out, fmt=args.format)
|
||||
|
||||
# Report (never prints passwords)
|
||||
warn_rows = [r for r in report if r["warnings"]]
|
||||
print(f"[OK] wrote {len(rows)} staff -> {out} (format={args.format})")
|
||||
print("[INFO] columns:", " | ".join(ib.HEADERS_BY_FORMAT[args.format]))
|
||||
for r in report:
|
||||
for n in r["notes"]:
|
||||
print(f" [INFO] row {r['row']} ({r['name']}): {n}")
|
||||
for r in warn_rows:
|
||||
for w in r["warnings"]:
|
||||
print(f" [WARNING] row {r['row']} ({r['name']}): {w}")
|
||||
if sidecar:
|
||||
print(f"[CRITICAL] generated passwords written PLAINTEXT to {sidecar}")
|
||||
print(" Distribute to staff, then DELETE or vault it. Never commit it.")
|
||||
print(f"\n[NEXT] Upload the .xls in ALIS: Staff -> Import. Dates use "
|
||||
f"{ib.DATE_FORMAT_HINT}. Rows without an ALIS ID are CREATED; the update "
|
||||
"format (with ALIS ID) edits existing staff. Test with ONE row first.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_inspect(args) -> int:
|
||||
data = ib.read_workbook(args.path)
|
||||
if args.json:
|
||||
_emit(data, True)
|
||||
return 0
|
||||
print(f"[INFO] format: {data['format']}")
|
||||
for name, rows in data["sheets"].items():
|
||||
print(f"=== {name} ({len(rows)} rows shown) ===")
|
||||
for i, row in enumerate(rows):
|
||||
trimmed = list(row)
|
||||
while trimmed and trimmed[-1] in ("", None):
|
||||
trimmed.pop()
|
||||
print(f" row{i}: {trimmed}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="alis", description="ALIS staff read + import-builder")
|
||||
p.add_argument("--json", action="store_true", help="emit raw JSON")
|
||||
p.add_argument("--community", type=int, default=None,
|
||||
help="communityId (default: vault/env, Cascades=622)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("auth-test", help="mint a token and list communities")
|
||||
sub.add_parser("communities", help="list communities in scope")
|
||||
|
||||
sp = sub.add_parser("staff", help="staff roster")
|
||||
sp.add_argument("--status", help="filter: Applicant|Hired|Discharged|Rejected")
|
||||
sp.add_argument("--limit", type=int, default=25)
|
||||
|
||||
sub.add_parser("roles", help="security + job role vocabulary (live)")
|
||||
|
||||
sp = sub.add_parser("role-map", help="job-role -> security-role reference")
|
||||
sp.add_argument("--refresh", action="store_true", help="rebuild from live + cache")
|
||||
|
||||
sp = sub.add_parser("template", help="write a blank import template .xls")
|
||||
sp.add_argument("--out", required=True)
|
||||
|
||||
sp = sub.add_parser("build-import", help="build an import .xls from a CSV/JSON")
|
||||
sp.add_argument("--input", required=True, help="CSV/JSON of new staff")
|
||||
sp.add_argument("--out", required=True, help="output .xls path")
|
||||
sp.add_argument("--format", choices=["create", "update"], default="create",
|
||||
help="create = new staff (has Password); update = edit "
|
||||
"existing (leads with ALIS ID)")
|
||||
sp.add_argument("--default-status", default="Hired")
|
||||
sp.add_argument("--no-suggest", action="store_true",
|
||||
help="do not infer Security Roles from Job Role")
|
||||
sp.add_argument("--refresh-roles", action="store_true",
|
||||
help="pull live role-map instead of cached")
|
||||
sp.add_argument("--gen-passwords", action="store_true",
|
||||
help="generate passwords for Login-enabled rows lacking one "
|
||||
"(writes a plaintext sidecar CSV)")
|
||||
|
||||
sp = sub.add_parser("inspect", help="dump an existing import workbook")
|
||||
sp.add_argument("path")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"auth-test": cmd_auth_test, "communities": cmd_communities, "staff": cmd_staff,
|
||||
"roles": cmd_roles, "role-map": cmd_role_map, "template": cmd_template,
|
||||
"build-import": cmd_build_import, "inspect": cmd_inspect,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return _DISPATCH[args.cmd](args)
|
||||
except (ALISError, ib.ImportBuilderError) as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
if _should_log(str(exc)):
|
||||
_log_skill_error("alis", str(exc), context=f"cmd={args.cmd}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
307
.claude/skills/alis/scripts/alis_client.py
Normal file
307
.claude/skills/alis/scripts/alis_client.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ALIS (Medtelligent assisted-living EHR) REST API client for the `alis` skill.
|
||||
|
||||
ALIS is a partner/App-Store integration API at https://api.alisonline.com. A
|
||||
tenant (e.g. Cascades of Tucson) lives at <tenant>.alisonline.com but ALL API
|
||||
traffic goes to the shared api.alisonline.com host, scoped by the logged-in
|
||||
user's company + a communityId.
|
||||
|
||||
Auth (verified live 2026-06-29):
|
||||
POST /user/tokens {"username":"<user>@<tenantKey>","password":"..."}
|
||||
-> {accessToken (JWT, ~1h), expiresIn, refreshToken}
|
||||
Send Authorization: Bearer <accessToken> on every call.
|
||||
Refresh: POST /user/tokens/refresh {accessToken, refreshToken}.
|
||||
CRITICAL: the username MUST be tenant-qualified (e.g. howard.enos@cascadestucson)
|
||||
or /user/tokens returns 400. The bare login name alone fails.
|
||||
Global API security is OR(Bearer | BasicAuth | VendorKey) - a user JWT alone
|
||||
authorizes the integration reads we use.
|
||||
|
||||
Scope reality: this API is READ-ONLY for staff (only GET endpoints exist - no
|
||||
create/update/delete). Staff are CHANGED via the ALIS web-UI bulk import (see
|
||||
import_builder.py). This client's job is to READ current staff so new staff can
|
||||
be set up the same way (the job-role -> security-role reference map).
|
||||
|
||||
Credentials load from the SOPS vault; env overrides exist for testing.
|
||||
Transport: prefers httpx if installed, else stdlib urllib (no hard dependency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
import httpx # type: ignore
|
||||
|
||||
_HAS_HTTPX = True
|
||||
except ImportError: # pragma: no cover - depends on environment
|
||||
_HAS_HTTPX = False
|
||||
|
||||
ERROR_BODY_MAX_CHARS = 500
|
||||
|
||||
ALIS_BASE_URL = os.environ.get("ALIS_BASE_URL", "https://api.alisonline.com")
|
||||
TIMEOUT_SECONDS = 60.0
|
||||
CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Cascades of Tucson is the only tenant this credential sees today.
|
||||
DEFAULT_COMMUNITY_ID = int(os.environ.get("ALIS_COMMUNITY_ID", "622"))
|
||||
|
||||
VAULT_ENTRY = "clients/cascades-tucson/alis-api-howard-user.sops.yaml"
|
||||
VAULT_USERNAME_FIELD = "credentials.username"
|
||||
VAULT_PASSWORD_FIELD = "credentials.password"
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class ALISError(RuntimeError):
|
||||
"""Raised for transport or API errors."""
|
||||
|
||||
|
||||
# --- credential loading -------------------------------------------------------
|
||||
def _resolve_claudetools_root() -> Path:
|
||||
derived_root = SKILL_DIR.parent.parent.parent # skills/alis -> repo root
|
||||
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
identity_path = derived_root / ".claude" / "identity.json"
|
||||
if identity_path.exists():
|
||||
try:
|
||||
data = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
root = data.get("claudetools_root")
|
||||
if root:
|
||||
return Path(root)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return derived_root
|
||||
|
||||
|
||||
def _vault_get(field: str) -> str:
|
||||
root = _resolve_claudetools_root()
|
||||
vault_script = root / ".claude" / "scripts" / "vault.sh"
|
||||
if not vault_script.exists():
|
||||
raise ALISError(f"vault wrapper not found at {vault_script}")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["bash", str(vault_script), "get-field", VAULT_ENTRY, field],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ALISError("'bash' not found on PATH; install Git Bash.") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ALISError("vault call timed out.") from exc
|
||||
if completed.returncode != 0:
|
||||
raise ALISError(
|
||||
f"vault read failed for {field} (exit {completed.returncode}): "
|
||||
f"{completed.stderr.strip()}"
|
||||
)
|
||||
val = completed.stdout.strip()
|
||||
if not val:
|
||||
raise ALISError(f"vault returned empty value for {field}.")
|
||||
return val
|
||||
|
||||
|
||||
def load_credentials() -> tuple[str, str]:
|
||||
"""Return (username, password). Env overrides ALIS_USERNAME/ALIS_PASSWORD,
|
||||
else the SOPS vault. Username must already be tenant-qualified."""
|
||||
user = os.environ.get("ALIS_USERNAME")
|
||||
pw = os.environ.get("ALIS_PASSWORD")
|
||||
if not user:
|
||||
user = _vault_get(VAULT_USERNAME_FIELD)
|
||||
if not pw:
|
||||
pw = _vault_get(VAULT_PASSWORD_FIELD)
|
||||
return user, pw
|
||||
|
||||
|
||||
# --- client -------------------------------------------------------------------
|
||||
class ALISClient:
|
||||
def __init__(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
api_base_url: str = ALIS_BASE_URL,
|
||||
community_id: int = DEFAULT_COMMUNITY_ID,
|
||||
timeout: float = TIMEOUT_SECONDS,
|
||||
connect_timeout: float = CONNECT_TIMEOUT_SECONDS,
|
||||
):
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.community_id = community_id
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._access_token: Optional[str] = None
|
||||
self._refresh_token: Optional[str] = None
|
||||
self.timeout = timeout
|
||||
self.connect_timeout = connect_timeout
|
||||
|
||||
# -- auth ------------------------------------------------------------------
|
||||
def _ensure_credentials(self) -> None:
|
||||
if not self._username or not self._password:
|
||||
self._username, self._password = load_credentials()
|
||||
|
||||
@property
|
||||
def access_token(self) -> str:
|
||||
if not self._access_token:
|
||||
self.authenticate()
|
||||
return self._access_token # type: ignore[return-value]
|
||||
|
||||
def authenticate(self) -> None:
|
||||
"""Mint a fresh JWT via POST /user/tokens."""
|
||||
self._ensure_credentials()
|
||||
body = {"username": self._username, "password": self._password}
|
||||
url = f"{self.api_base_url}/user/tokens"
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
result = self._send("POST", url, data, with_auth=False)
|
||||
if not isinstance(result, dict) or "accessToken" not in result:
|
||||
raise ALISError(f"Unexpected token response: {str(result)[:200]}")
|
||||
self._access_token = result["accessToken"]
|
||||
self._refresh_token = result.get("refreshToken")
|
||||
|
||||
# -- core transport --------------------------------------------------------
|
||||
def _request(self, method: str, path: str,
|
||||
params: Optional[dict] = None,
|
||||
body: Optional[dict] = None) -> Any:
|
||||
url = f"{self.api_base_url}/{path.lstrip('/')}"
|
||||
if params:
|
||||
url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
return self._send(method, url, data, with_auth=True)
|
||||
|
||||
def _send(self, method: str, url: str, data: Optional[bytes],
|
||||
with_auth: bool) -> Any:
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if with_auth:
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
if _HAS_HTTPX:
|
||||
try:
|
||||
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.request(method, url, content=data, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else None
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ALISError(f"ALIS request timed out: {exc}") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
raise ALISError(
|
||||
f"ALIS HTTP {exc.response.status_code} [{method} {url}]: {detail}"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ALISError(f"ALIS request failed: {exc}") from exc
|
||||
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8")) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
raise ALISError(f"ALIS HTTP {exc.code} [{method} {url}]: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ALISError(f"ALIS request failed: {exc}") from exc
|
||||
|
||||
# ======================================================================
|
||||
# READ METHODS (this API is read-only for staff)
|
||||
# ======================================================================
|
||||
def list_communities(self) -> list[dict]:
|
||||
"""Communities the logged-in user can see. VERIFIED LIVE."""
|
||||
return self._request("GET", "/v1/integration/communities") or []
|
||||
|
||||
def list_staff(self, community_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
include_associated: bool = False) -> list[dict]:
|
||||
"""Staff roster for a community. VERIFIED LIVE.
|
||||
communityId is REQUIRED - omitting it 403s 'Not authorized for facility 0'.
|
||||
Each record: staffId, firstName, lastName, staffRecordNumber, primaryEmail,
|
||||
mobilePhoneNumber, dateOfBirth, status, hireDate, dischargeDate, jobRole,
|
||||
securityRoles (list)."""
|
||||
params: dict = {"communityId": community_id or self.community_id}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if include_associated:
|
||||
params["includeAssociatedStaff"] = "true"
|
||||
res = self._request("GET", "/v1/integration/staff", params=params)
|
||||
return res if isinstance(res, list) else (res or {}).get("data", []) or []
|
||||
|
||||
def get_staff(self, staff_id: int) -> dict:
|
||||
"""One staff member's full record. VERIFIED LIVE."""
|
||||
return self._request("GET", f"/v1/integration/staff/{staff_id}") or {}
|
||||
|
||||
def get_staff_basic_info(self, staff_id: int) -> dict:
|
||||
"""Staff basicInfo: address, license, jobRole, securityRoles, etc."""
|
||||
return self._request(
|
||||
"GET", f"/v1/integration/staff/{staff_id}/basicInfo") or {}
|
||||
|
||||
# -- derived reference model -----------------------------------------------
|
||||
def build_role_map(self, community_id: Optional[int] = None) -> dict:
|
||||
"""Derive the setup-reference from LIVE staff: the security-role and
|
||||
job-role vocabularies actually in use, and a job-role -> security-role(s)
|
||||
map learned from HIRED staff. This is how a new hire of a given job role
|
||||
should be configured to match existing staff."""
|
||||
staff = self.list_staff(community_id=community_id)
|
||||
hired = [s for s in staff if s.get("status") == "Hired"]
|
||||
sr_all: Counter = Counter()
|
||||
jr_all: Counter = Counter()
|
||||
jr_to_sr: dict[str, Counter] = defaultdict(Counter)
|
||||
for s in staff:
|
||||
sr = s.get("securityRoles") or []
|
||||
if isinstance(sr, str):
|
||||
sr = [sr]
|
||||
for x in sr:
|
||||
sr_all[x] += 1
|
||||
jr = (s.get("jobRole") or "").strip()
|
||||
if jr:
|
||||
jr_all[jr] += 1
|
||||
for s in hired:
|
||||
jr = (s.get("jobRole") or "").strip()
|
||||
if not jr:
|
||||
continue
|
||||
sr = s.get("securityRoles") or []
|
||||
if isinstance(sr, str):
|
||||
sr = [sr]
|
||||
for x in sr:
|
||||
jr_to_sr[jr][x] += 1
|
||||
return {
|
||||
"community": {"id": community_id or self.community_id},
|
||||
"sourceStaffCount": len(staff),
|
||||
"hiredCount": len(hired),
|
||||
"securityRoleVocabulary": sorted(sr_all),
|
||||
"jobRoleVocabulary": sorted(jr_all),
|
||||
"jobRoleToSecurityRoles": {
|
||||
jr: [r for r, _ in c.most_common()] for jr, c in jr_to_sr.items()
|
||||
},
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# POWER TOOL
|
||||
# ======================================================================
|
||||
def raw(self, method: str, path: str,
|
||||
params: Optional[dict] = None, body: Optional[dict] = None) -> Any:
|
||||
"""Call any endpoint directly (read-only API - no staff writes exist)."""
|
||||
return self._request(method.upper(), path, params=params, body=body)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Self-check: mint a token (live) and confirm community scope."""
|
||||
try:
|
||||
client = ALISClient()
|
||||
client.authenticate()
|
||||
print("[OK] authenticated; transport =",
|
||||
"httpx" if _HAS_HTTPX else "urllib")
|
||||
comms = client.list_communities()
|
||||
print("[INFO] base =", client.api_base_url)
|
||||
print("[INFO] communities:",
|
||||
[(c.get("communityId"), c.get("communityName")) for c in comms])
|
||||
return 0
|
||||
except ALISError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
350
.claude/skills/alis/scripts/import_builder.py
Normal file
350
.claude/skills/alis/scripts/import_builder.py
Normal file
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build / inspect the ALIS Staff Import workbook for the `alis` skill.
|
||||
|
||||
ALIS has NO staff-write API. Staff (and their logins) are created/changed in
|
||||
bulk by uploading an .xls on the ALIS web UI (Staff -> Import). This module
|
||||
produces a workbook that exactly matches Medtelligent's template so it uploads
|
||||
cleanly, and validates each row against the template's controlled values and
|
||||
against the live job-role -> security-role reference (role-map.json).
|
||||
|
||||
Template (Sheet1 header, exact order - DO NOT reorder/rename):
|
||||
First Name | Last Name | Staff Record Number | Security Roles | Staff Status |
|
||||
Hire Date | Login Enabled | Email | Password | Date of Birth | Gender |
|
||||
Job Role | Cell Phone
|
||||
Sheet2 holds the dropdown lists:
|
||||
Staff Status: Applicant / Discharged / Hired / Rejected
|
||||
Login Enabled: Yes / No
|
||||
Gender: Female / Male
|
||||
|
||||
Write uses xlwt (legacy .xls, matching the template format). Read uses xlrd.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# ALIS has TWO import layouts (column order matters for the importer):
|
||||
# - CREATE (blank "new staff" template): has Password, NO ALIS ID. Rows WITHOUT
|
||||
# an ALIS ID are created as new staff.
|
||||
# - UPDATE (export of current staff): leads with ALIS ID, NO Password. Rows WITH
|
||||
# an ALIS ID update the matching existing staff member.
|
||||
# We build CREATE files for new hires by default.
|
||||
TEMPLATE_HEADERS = [
|
||||
"First Name", "Last Name", "Staff Record Number", "Security Roles",
|
||||
"Staff Status", "Hire Date", "Login Enabled", "Email", "Password",
|
||||
"Date of Birth", "Gender", "Job Role", "Cell Phone",
|
||||
]
|
||||
UPDATE_TEMPLATE_HEADERS = [
|
||||
"ALIS ID", "First Name", "Last Name", "Staff Record Number", "Security Roles",
|
||||
"Staff Status", "Hire Date", "Login Enabled", "Email",
|
||||
"Date of Birth", "Gender", "Job Role", "Cell Phone",
|
||||
]
|
||||
HEADERS_BY_FORMAT = {"create": TEMPLATE_HEADERS, "update": UPDATE_TEMPLATE_HEADERS}
|
||||
|
||||
# ALIS dates render as MM/DD/YYYY (confirmed from the real staff export).
|
||||
DATE_FORMAT_HINT = "MM/DD/YYYY"
|
||||
|
||||
# Sheet2 dropdown lists (mirror the official template).
|
||||
STATUS_VALUES = ["Applicant", "Discharged", "Hired", "Rejected"]
|
||||
LOGIN_VALUES = ["Yes", "No"]
|
||||
GENDER_VALUES = ["Female", "Male"]
|
||||
|
||||
# Accepted input aliases -> canonical header. Lower-cased, stripped, non-alnum
|
||||
# removed for matching, so "first_name", "FirstName", "First Name" all map.
|
||||
_ALIASES = {
|
||||
"alisid": "ALIS ID", "alis": "ALIS ID", "staffaliasid": "ALIS ID",
|
||||
"firstname": "First Name", "first": "First Name", "fname": "First Name",
|
||||
"lastname": "Last Name", "last": "Last Name", "lname": "Last Name",
|
||||
"staffrecordnumber": "Staff Record Number", "recordnumber": "Staff Record Number",
|
||||
"recordno": "Staff Record Number", "employeeid": "Staff Record Number",
|
||||
"empid": "Staff Record Number", "staffid": "Staff Record Number",
|
||||
"securityroles": "Security Roles", "securityrole": "Security Roles",
|
||||
"role": "Security Roles", "accessrole": "Security Roles",
|
||||
"staffstatus": "Staff Status", "status": "Staff Status",
|
||||
"hiredate": "Hire Date", "datehired": "Hire Date", "startdate": "Hire Date",
|
||||
"loginenabled": "Login Enabled", "login": "Login Enabled",
|
||||
"loginaccess": "Login Enabled", "portalaccess": "Login Enabled",
|
||||
"email": "Email", "emailaddress": "Email", "workemail": "Email",
|
||||
"password": "Password", "pass": "Password", "pwd": "Password",
|
||||
"dateofbirth": "Date of Birth", "dob": "Date of Birth", "birthdate": "Date of Birth",
|
||||
"gender": "Gender", "sex": "Gender",
|
||||
"jobrole": "Job Role", "jobtitle": "Job Role", "title": "Job Role",
|
||||
"position": "Job Role",
|
||||
"cellphone": "Cell Phone", "cell": "Cell Phone", "mobile": "Cell Phone",
|
||||
"phone": "Cell Phone", "mobilephone": "Cell Phone", "cellphonenumber": "Cell Phone",
|
||||
}
|
||||
|
||||
|
||||
class ImportBuilderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return "".join(ch for ch in str(s).lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _canon_header(raw: str) -> Optional[str]:
|
||||
raw_s = str(raw).strip()
|
||||
for h in TEMPLATE_HEADERS:
|
||||
if _norm(h) == _norm(raw_s):
|
||||
return h
|
||||
return _ALIASES.get(_norm(raw_s))
|
||||
|
||||
|
||||
def load_role_map(path: Optional[Path] = None) -> dict:
|
||||
"""Load the cached job-role -> security-role reference (role-map.json)."""
|
||||
if path is None:
|
||||
path = Path(__file__).resolve().parent.parent / "references" / "role-map.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
# --- input parsing ------------------------------------------------------------
|
||||
def read_input_rows(path: str) -> list[dict]:
|
||||
"""Read a CSV or JSON file into a list of {canonical-header: value} dicts.
|
||||
Unknown columns are ignored (with the raw key preserved under '_extra')."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise ImportBuilderError(f"input file not found: {path}")
|
||||
text = p.read_text(encoding="utf-8-sig")
|
||||
raw_rows: list[dict]
|
||||
if p.suffix.lower() == ".json":
|
||||
data = json.loads(text)
|
||||
raw_rows = data if isinstance(data, list) else data.get("staff") or data.get("rows") or []
|
||||
else: # CSV/TSV
|
||||
delim = "\t" if p.suffix.lower() in (".tsv", ".tab") else ","
|
||||
raw_rows = list(csv.DictReader(text.splitlines(), delimiter=delim))
|
||||
rows: list[dict] = []
|
||||
for raw in raw_rows:
|
||||
row: dict = {}
|
||||
extra: dict = {}
|
||||
for k, v in raw.items():
|
||||
if k is None:
|
||||
continue
|
||||
canon = _canon_header(k)
|
||||
if canon:
|
||||
row[canon] = "" if v is None else str(v).strip()
|
||||
else:
|
||||
extra[k] = v
|
||||
if extra:
|
||||
row["_extra"] = extra
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# --- validation + enrichment --------------------------------------------------
|
||||
def _suggest_security_role(job_role: str, role_map: dict) -> Optional[str]:
|
||||
"""Return the typical FULL Security Roles string (comma-separated, as ALIS
|
||||
stores it) for a job role, learned from current staff. Prefers the modal
|
||||
exact combo (jobRoleToSecurityRolesCombo); falls back to the older
|
||||
single-role map for compatibility."""
|
||||
if not job_role:
|
||||
return None
|
||||
combo = role_map.get("jobRoleToSecurityRolesCombo", {})
|
||||
for k, v in combo.items():
|
||||
if k.lower() == job_role.lower() and v:
|
||||
return v
|
||||
# legacy fallback: list of roles -> join
|
||||
mapping = role_map.get("jobRoleToSecurityRoles", {})
|
||||
for k, v in mapping.items():
|
||||
if k.lower() == job_role.lower() and v:
|
||||
return ", ".join(v) if isinstance(v, list) else str(v)
|
||||
return None
|
||||
|
||||
|
||||
def enrich_and_validate(rows: list[dict], role_map: dict,
|
||||
default_status: str = "Hired",
|
||||
suggest_roles: bool = True) -> tuple[list[dict], list[dict]]:
|
||||
"""Apply defaults, infer Security Roles from Job Role, validate enums.
|
||||
Returns (processed_rows, report) where report has per-row notes/warnings."""
|
||||
sec_vocab = set(role_map.get("securityRoleVocabulary", []))
|
||||
report: list[dict] = []
|
||||
out: list[dict] = []
|
||||
for i, r in enumerate(rows):
|
||||
notes: list[str] = []
|
||||
warns: list[str] = []
|
||||
# Keep every known header (union of both layouts) so ALIS ID survives for
|
||||
# update rows; write_workbook selects the columns per chosen format.
|
||||
all_headers = ["ALIS ID"] + TEMPLATE_HEADERS
|
||||
row = {h: str(r.get(h, "") or "").strip() for h in all_headers}
|
||||
|
||||
if not row["First Name"] or not row["Last Name"]:
|
||||
warns.append("missing First/Last Name (required)")
|
||||
|
||||
# Status
|
||||
if not row["Staff Status"]:
|
||||
row["Staff Status"] = default_status
|
||||
notes.append(f"Staff Status defaulted to {default_status}")
|
||||
elif row["Staff Status"] not in STATUS_VALUES:
|
||||
warns.append(f"Staff Status '{row['Staff Status']}' not in {STATUS_VALUES}")
|
||||
|
||||
# Gender
|
||||
if row["Gender"]:
|
||||
g = row["Gender"].strip().capitalize()
|
||||
if g in GENDER_VALUES:
|
||||
row["Gender"] = g
|
||||
else:
|
||||
warns.append(f"Gender '{row['Gender']}' not in {GENDER_VALUES}")
|
||||
|
||||
# Security Roles - infer from Job Role if blank
|
||||
if not row["Security Roles"] and suggest_roles:
|
||||
sug = _suggest_security_role(row["Job Role"], role_map)
|
||||
if sug:
|
||||
row["Security Roles"] = sug
|
||||
notes.append(f"Security Roles inferred '{sug}' from Job Role "
|
||||
f"'{row['Job Role']}' (review)")
|
||||
elif row["Job Role"]:
|
||||
warns.append(f"no reference Security Role for Job Role "
|
||||
f"'{row['Job Role']}' - set manually")
|
||||
elif row["Security Roles"] and sec_vocab:
|
||||
unknown = [p.strip() for p in row["Security Roles"].split(",")
|
||||
if p.strip() and p.strip() not in sec_vocab]
|
||||
if unknown:
|
||||
warns.append(f"Security Role(s) {unknown} not seen in current staff "
|
||||
"- confirm they're real ALIS roles")
|
||||
|
||||
# Login Enabled - default from presence of email+password
|
||||
if not row["Login Enabled"]:
|
||||
if row["Email"] and row["Password"]:
|
||||
row["Login Enabled"] = "Yes"
|
||||
notes.append("Login Enabled defaulted to Yes (email+password present)")
|
||||
else:
|
||||
row["Login Enabled"] = "No"
|
||||
notes.append("Login Enabled defaulted to No (no email/password)")
|
||||
elif row["Login Enabled"] not in LOGIN_VALUES:
|
||||
warns.append(f"Login Enabled '{row['Login Enabled']}' not in {LOGIN_VALUES}")
|
||||
|
||||
if row["Login Enabled"] == "Yes" and not row["Email"]:
|
||||
warns.append("Login Enabled=Yes but no Email (login needs an email)")
|
||||
|
||||
out.append(row)
|
||||
report.append({
|
||||
"row": i + 1,
|
||||
"name": f"{row['First Name']} {row['Last Name']}".strip(),
|
||||
"notes": notes, "warnings": warns,
|
||||
})
|
||||
return out, report
|
||||
|
||||
|
||||
def generate_password(length: int = 14) -> str:
|
||||
"""Strong random password (letters+digits+symbols), avoids ambiguous chars."""
|
||||
alphabet = (string.ascii_uppercase.replace("O", "").replace("I", "")
|
||||
+ string.ascii_lowercase.replace("l", "")
|
||||
+ string.digits.replace("0", "").replace("1", "")
|
||||
+ "!@#$%*?")
|
||||
while True:
|
||||
pw = "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
if (any(c.islower() for c in pw) and any(c.isupper() for c in pw)
|
||||
and any(c.isdigit() for c in pw) and any(c in "!@#$%*?" for c in pw)):
|
||||
return pw
|
||||
|
||||
|
||||
def fill_passwords(rows: list[dict]) -> list[dict]:
|
||||
"""For Login Enabled=Yes rows missing a Password, generate one in place.
|
||||
Returns the list of {name, email, password} that were generated."""
|
||||
generated = []
|
||||
for row in rows:
|
||||
if row.get("Login Enabled") == "Yes" and not row.get("Password"):
|
||||
pw = generate_password()
|
||||
row["Password"] = pw
|
||||
generated.append({
|
||||
"name": f"{row['First Name']} {row['Last Name']}".strip(),
|
||||
"email": row.get("Email", ""), "password": pw,
|
||||
})
|
||||
return generated
|
||||
|
||||
|
||||
# --- workbook writing ---------------------------------------------------------
|
||||
def write_workbook(rows: list[dict], out_path: str, fmt: str = "create") -> str:
|
||||
"""Write the rows to an .xls matching the ALIS template (Sheet1 data +
|
||||
Sheet2 dropdown lists). fmt='create' (new staff, has Password) or 'update'
|
||||
(existing staff, leads with ALIS ID). Returns the output path."""
|
||||
headers = HEADERS_BY_FORMAT.get(fmt)
|
||||
if headers is None:
|
||||
raise ImportBuilderError(f"unknown format '{fmt}' (use create|update)")
|
||||
try:
|
||||
import xlwt # type: ignore
|
||||
except ImportError as exc:
|
||||
raise ImportBuilderError(
|
||||
"xlwt is required to write .xls. Install with: pip install xlwt"
|
||||
) from exc
|
||||
|
||||
wb = xlwt.Workbook(encoding="utf-8")
|
||||
header_style = xlwt.easyxf("font: bold on; align: wrap on")
|
||||
s1 = wb.add_sheet("Sheet1")
|
||||
for c, h in enumerate(headers):
|
||||
s1.write(0, c, h, header_style)
|
||||
for ri, row in enumerate(rows, start=1):
|
||||
for ci, h in enumerate(headers):
|
||||
s1.write(ri, ci, row.get(h, ""))
|
||||
|
||||
# Sheet2 = validation lists, laid out exactly like the official template:
|
||||
# col0 Status, col1 Login Enabled, col2 Gender.
|
||||
s2 = wb.add_sheet("Sheet2")
|
||||
lists = [STATUS_VALUES, LOGIN_VALUES, GENDER_VALUES]
|
||||
for col, values in enumerate(lists):
|
||||
for r, v in enumerate(values):
|
||||
s2.write(r, col, v)
|
||||
|
||||
out = Path(out_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
wb.save(str(out))
|
||||
return str(out)
|
||||
|
||||
|
||||
def write_password_sidecar(generated: list[dict], xls_path: str) -> Optional[str]:
|
||||
"""Write generated logins to a sidecar CSV next to the workbook. Contains
|
||||
PLAINTEXT passwords - caller must warn + tell the user to distribute then
|
||||
delete/vault it. Returns the sidecar path, or None if nothing generated."""
|
||||
if not generated:
|
||||
return None
|
||||
side = Path(xls_path).with_name(Path(xls_path).stem + "_passwords.csv")
|
||||
with side.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["Name", "Email", "Password"])
|
||||
for g in generated:
|
||||
w.writerow([g["name"], g["email"], g["password"]])
|
||||
return str(side)
|
||||
|
||||
|
||||
# --- workbook reading (inspect) -----------------------------------------------
|
||||
def read_workbook(path: str, max_rows: int = 50) -> dict:
|
||||
"""Read an existing .xls (or .xlsx) staff-import workbook for inspection."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise ImportBuilderError(f"workbook not found: {path}")
|
||||
if p.suffix.lower() == ".xlsx":
|
||||
try:
|
||||
import openpyxl # type: ignore
|
||||
except ImportError as exc:
|
||||
raise ImportBuilderError("openpyxl required for .xlsx") from exc
|
||||
wb = openpyxl.load_workbook(str(p), read_only=True, data_only=True)
|
||||
sheets = {}
|
||||
for sh in wb.worksheets:
|
||||
rows = []
|
||||
for ri, row in enumerate(sh.iter_rows(values_only=True)):
|
||||
if ri >= max_rows:
|
||||
break
|
||||
rows.append([("" if c is None else c) for c in row])
|
||||
sheets[sh.title] = rows
|
||||
return {"format": "xlsx", "sheets": sheets}
|
||||
try:
|
||||
import xlrd # type: ignore
|
||||
except ImportError as exc:
|
||||
raise ImportBuilderError("xlrd required for .xls") from exc
|
||||
wb = xlrd.open_workbook(str(p))
|
||||
sheets = {}
|
||||
for sh in wb.sheets():
|
||||
rows = []
|
||||
for r in range(min(sh.nrows, max_rows)):
|
||||
rows.append([sh.cell_value(r, c) for c in range(sh.ncols)])
|
||||
sheets[sh.name] = rows
|
||||
return {"format": "xls", "sheets": sheets}
|
||||
@@ -1,12 +1,6 @@
|
||||
---
|
||||
name: b2
|
||||
description: >-
|
||||
Manage ACG's Backblaze B2 storage account (Native API v3) — the LIVE production
|
||||
account (accountId 46f69bc61163, us-west-001) holding per-client MSP360/CloudBerry
|
||||
backup destinations. List buckets/keys/files, compute per-bucket size, run the
|
||||
headline storage-cost report (mspbackups calc); provision/delete buckets and scoped
|
||||
keys (destructive ops gated behind --confirm). Read-only by default. Triggers:
|
||||
backblaze, b2, b2 storage, bucket, storage cost, backup storage, mspbackups storage.
|
||||
description: "Manage ACG's live Backblaze B2 account (per-client MSP360 backup destinations): buckets/keys/files, per-bucket size, storage-cost report; provisioning/deletes gated --confirm. Triggers: backblaze, b2, bucket, storage cost, backup storage, mspbackups storage."
|
||||
---
|
||||
|
||||
# Backblaze B2 Skill
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
---
|
||||
name: bitdefender
|
||||
description: >-
|
||||
Manage the ACG Bitdefender GravityZone Cloud MSP tenant (Public JSON-RPC API):
|
||||
inventory/audit endpoints, live security sweeps (infected / outdated-signature /
|
||||
outdated-product), client companies, install packages, custom groups, scans,
|
||||
move/delete endpoints (gated), policies (full read + assign), reports, accounts,
|
||||
scan tasks, notifications, push event service, quarantine, EDR (isolate /
|
||||
blocklist). Live production partner tenant — treat destructive actions
|
||||
conservatively. Triggers: bitdefender, gravityzone, install bitdefender on, list
|
||||
endpoints, infected machines, av coverage, security sweep, endpoint protection,
|
||||
assign policy, quarantine, reports, accounts.
|
||||
description: "Manage the ACG Bitdefender GravityZone Cloud MSP tenant (JSON-RPC API): endpoint inventory/audit, security sweeps, packages, policies, quarantine, EDR isolate/blocklist; destructive ops gated. Triggers: bitdefender, gravityzone, infected machines, av coverage, assign policy."
|
||||
---
|
||||
|
||||
# Bitdefender GravityZone Skill
|
||||
@@ -51,8 +42,8 @@ empty password).
|
||||
## Cache model (important)
|
||||
|
||||
The CLI keeps a local cache at
|
||||
`.claude/skills/bitdefender/.cache/inventory.json` (gitignored — no secrets, no
|
||||
PII).
|
||||
`.claude/skills/bitdefender/.cache/inventory.json` (gitignored — no secrets; it
|
||||
does hold infra identifiers: endpoint hostnames/FQDNs and id<->name maps).
|
||||
|
||||
- **Cached (identity / structure tier):** company id<->name map, endpoint
|
||||
id<->name/company/fqdn map, policy id<->name map, package list, and custom
|
||||
@@ -86,7 +77,7 @@ Destructive subcommands refuse to run without `--confirm`; without it they print
|
||||
what they would do and exit non-zero:
|
||||
|
||||
- `delete-endpoint <id> --confirm`
|
||||
- `delete-package --package <name> --confirm`
|
||||
- `delete-package --id <packageId> --confirm` (param is `packageId`, NOT packageName)
|
||||
- `delete-group --group <id> --confirm`
|
||||
- `isolate --endpoints <id> ... --confirm` (cuts the endpoint off the network; reversible via `unisolate`)
|
||||
- `unisolate --endpoints <id> ... --confirm`
|
||||
@@ -94,6 +85,10 @@ what they would do and exit non-zero:
|
||||
- `blocklist-remove --id <hashItemId> --confirm`
|
||||
- `assign-policy --policy <id> --targets <id> ... --confirm` (applies an existing policy to endpoints/groups)
|
||||
- `push-set --status 1 --url <receiver> --confirm` (configures the GravityZone push event service)
|
||||
- `move --endpoints <id> ... --group <id> --confirm` (relocating endpoints changes their INHERITED policy — gated 2026-06-25)
|
||||
- `scan --targets <id> ... --type <n> --confirm` (starts a scan on live endpoints — gated 2026-06-25)
|
||||
- `create-package --name <n> [--company <id>] --confirm` (creates an installer package — gated 2026-06-25)
|
||||
- `make-group --name <n> [--parent <id>] --confirm` (creates a custom group — gated 2026-06-25)
|
||||
|
||||
Never run destructive calls casually against this tenant. UNVERIFIED methods
|
||||
(assignPolicy, uninstall/reconfigure tasks, quarantine remove/restore, set
|
||||
|
||||
@@ -124,7 +124,7 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node.
|
||||
| `getPackagesList` | `page?, perPage<=100` | VERIFIED | List installation packages. |
|
||||
| `createPackage` | `packageName, companyId?, description?, language?, modules?, scanMode?, settings?, roles?, deploymentOptions?` | VERIFIED | Create an installer package. Returns the new package id. |
|
||||
| `getInstallationLinks` | `packageName, companyId?` | VERIFIED | Returns Windows / Mac / Linux installer download URLs for a package. |
|
||||
| `deletePackage` | `packageName, companyId?` | VERIFIED (destructive) | Delete a package. CLI-gated behind `--confirm`. |
|
||||
| `deletePackage` | `packageId` | VERIFIED (destructive) | Delete a package. Param is `packageId` (NOT packageName/companyId - those error "not expected", verified live 2026-06-21). CLI: `delete-package --id <packageId> --confirm`. |
|
||||
|
||||
## policies (`/policies`) — FULL READ + ASSIGN (authoring is console-only)
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the bitdefender skill - GravityZone Cloud Public API.
|
||||
|
||||
Read-only subcommands run freely. Destructive subcommands (delete-endpoint,
|
||||
delete-package, delete-group) refuse to run unless --confirm is passed; without
|
||||
it they print what they WOULD do and exit non-zero.
|
||||
Read-only subcommands run freely. State-changing subcommands (delete-endpoint,
|
||||
delete-package, delete-group, move, scan, create-package, make-group, isolate,
|
||||
unisolate, blocklist-add/remove, assign-policy, set-label, reconfigure, push-set,
|
||||
company create/suspend/activate/delete, account/report create, push-test, and any
|
||||
destructive `raw` method) refuse to run unless --confirm is passed; without it
|
||||
they print what they WOULD do and exit non-zero. See SKILL.md for the full list.
|
||||
|
||||
Output: --json emits raw JSON; otherwise a readable table/summary.
|
||||
|
||||
@@ -38,6 +41,7 @@ import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -263,10 +267,15 @@ def cmd_status(client, args):
|
||||
|
||||
|
||||
def cmd_companies(client, args):
|
||||
_emit(client.list_companies(), args.json, _print_company_table)
|
||||
# Show the FULL fleet (paginated), not just page 1, so a >100-company tenant
|
||||
# isn't silently truncated in the listing.
|
||||
items = client.list_all_companies()
|
||||
_emit({"total": len(items), "items": items}, args.json, _print_company_table)
|
||||
|
||||
|
||||
def cmd_company(client, args):
|
||||
if args.company_id and not _require_oid(args.company_id, "company"):
|
||||
return 2
|
||||
_emit(client.get_company_details(args.company_id), args.json, _print_kv)
|
||||
|
||||
|
||||
@@ -278,6 +287,8 @@ def cmd_company_create(client, args):
|
||||
extra, rc = _load_json_arg(args.extra_json, "extra-json")
|
||||
if rc:
|
||||
return rc
|
||||
if args.parent and not _require_oid(args.parent, "parent company"):
|
||||
return 2
|
||||
label = {0: "Partner", 1: "Customer"}.get(args.type, str(args.type))
|
||||
if not _gated(f"create {label} company '{args.name}'", args.confirm):
|
||||
return 3
|
||||
@@ -288,6 +299,8 @@ def cmd_company_create(client, args):
|
||||
|
||||
|
||||
def cmd_company_suspend(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"suspend company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"suspended": args.id, "result": client.suspend_company(args.id)},
|
||||
@@ -296,6 +309,8 @@ def cmd_company_suspend(client, args):
|
||||
|
||||
|
||||
def cmd_company_activate(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"activate company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"activated": args.id, "result": client.activate_company(args.id)},
|
||||
@@ -304,6 +319,8 @@ def cmd_company_activate(client, args):
|
||||
|
||||
|
||||
def cmd_company_delete(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"delete company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"deletedCompany": args.id, "result": client.delete_company(args.id)},
|
||||
@@ -312,15 +329,21 @@ def cmd_company_delete(client, args):
|
||||
|
||||
|
||||
def cmd_endpoints(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_endpoints(args.company, per_page=args.per_page),
|
||||
args.json, _print_endpoint_table)
|
||||
|
||||
|
||||
def cmd_endpoint(client, args):
|
||||
if not _require_oid(args.endpoint_id, "endpoint"):
|
||||
return 2
|
||||
_emit(client.get_endpoint_details(args.endpoint_id), args.json, _print_kv)
|
||||
|
||||
|
||||
def cmd_sweep(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
if args.company:
|
||||
summaries = client.security_sweep(args.company)
|
||||
else:
|
||||
@@ -333,13 +356,6 @@ def cmd_sweep(client, args):
|
||||
_print_sweep_table(summaries)
|
||||
|
||||
|
||||
def _require_company_for_sweep() -> str:
|
||||
from gz_client import ACG_COMPANIES_CONTAINER_ID
|
||||
print("[INFO] No --company given; sweeping the ACG companies container.",
|
||||
file=sys.stderr)
|
||||
return ACG_COMPANIES_CONTAINER_ID
|
||||
|
||||
|
||||
def cmd_policies(client, args):
|
||||
_emit(client.list_policies(), args.json, _print_policy_table)
|
||||
|
||||
@@ -348,6 +364,8 @@ def cmd_policy(client, args):
|
||||
# getPolicyDetails returns the FULL granular module configuration (verified
|
||||
# live 2026-06-21). Use --json for the complete settings tree; the table
|
||||
# view shows the top-level keys only.
|
||||
if not _require_oid(args.policy_id, "policy"):
|
||||
return 2
|
||||
_emit(client.get_policy_details(args.policy_id), args.json, _print_kv)
|
||||
|
||||
|
||||
@@ -366,6 +384,8 @@ def cmd_notif_settings(client, args):
|
||||
|
||||
|
||||
def cmd_account(client, args):
|
||||
if args.account_id and not _require_oid(args.account_id, "account"):
|
||||
return 2
|
||||
_emit(client.get_account_details(args.account_id), args.json, _print_kv)
|
||||
|
||||
|
||||
@@ -417,6 +437,8 @@ def cmd_account_update(client, args):
|
||||
print("[ERROR] --set-json (object of fields to change) is required.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not _require_oid(args.id, "account"):
|
||||
return 2
|
||||
if not _gated(f"update account {args.id} fields={list(fields)}", args.confirm):
|
||||
return 3
|
||||
result = client.update_account(args.id, fields)
|
||||
@@ -425,6 +447,8 @@ def cmd_account_update(client, args):
|
||||
|
||||
|
||||
def cmd_account_delete(client, args):
|
||||
if not _require_oid(args.id, "account"):
|
||||
return 2
|
||||
if not _gated(f"delete account {args.id}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_account(args.id)
|
||||
@@ -453,6 +477,11 @@ def cmd_scan_tasks(client, args):
|
||||
|
||||
|
||||
def cmd_assign_policy(client, args):
|
||||
if not _require_oid(args.policy, "policy"):
|
||||
return 2
|
||||
for t in args.targets:
|
||||
if not _require_oid(t, "target"):
|
||||
return 2
|
||||
desc = (f"assign policy {args.policy} to {len(args.targets)} target(s): "
|
||||
f"{','.join(args.targets)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
@@ -513,11 +542,11 @@ def cmd_push_set(client, args):
|
||||
|
||||
|
||||
def cmd_push_test(client, args):
|
||||
if not _gated(f"send test push event '{args.event_type}'", args.confirm):
|
||||
return 3
|
||||
extra, rc = _load_json_arg(args.extra_json, "extra-json")
|
||||
if rc:
|
||||
return rc
|
||||
if not _gated(f"send test push event '{args.event_type}'", args.confirm):
|
||||
return 3
|
||||
result = client.send_test_push_event(args.event_type, extra=extra or None)
|
||||
_emit({"testEvent": args.event_type, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
@@ -597,6 +626,10 @@ def cmd_incident_status(client, args):
|
||||
fields, rc = _load_json_arg(args.set_json, "set-json")
|
||||
if rc:
|
||||
return rc
|
||||
if not fields:
|
||||
print("[ERROR] --set-json (object of fields to set) is required.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not _gated(f"change incident status (type={args.type})", args.confirm):
|
||||
return 3
|
||||
result = client.change_incident_status(args.type, fields)
|
||||
@@ -608,6 +641,10 @@ def cmd_incident_note(client, args):
|
||||
fields, rc = _load_json_arg(args.set_json, "set-json")
|
||||
if rc:
|
||||
return rc
|
||||
if not fields:
|
||||
print("[ERROR] --set-json (object of fields to set) is required.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not _gated(f"update incident note (type={args.type})", args.confirm):
|
||||
return 3
|
||||
result = client.update_incident_note(args.type, fields)
|
||||
@@ -629,15 +666,21 @@ def cmd_packages(client, args):
|
||||
|
||||
|
||||
def cmd_quarantine(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_quarantine(args.company), args.json, _print_quarantine_table)
|
||||
|
||||
|
||||
def cmd_blocklist(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_blocklist(args.company, page=args.page, per_page=args.per_page),
|
||||
args.json, _print_blocklist_table)
|
||||
|
||||
|
||||
def cmd_incidents(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_incidents(args.company, page=args.page,
|
||||
per_page=args.per_page),
|
||||
args.json, _print_incidents_table)
|
||||
@@ -649,6 +692,12 @@ def cmd_inventory(client, args):
|
||||
|
||||
|
||||
def cmd_create_package(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
if not _gated(f"create installer package '{args.name}'"
|
||||
+ (f" in company {args.company}" if args.company else ""),
|
||||
args.confirm):
|
||||
return 3
|
||||
result = client.create_package(
|
||||
package_name=args.name,
|
||||
company_id=args.company,
|
||||
@@ -656,24 +705,45 @@ def cmd_create_package(client, args):
|
||||
language=args.language,
|
||||
)
|
||||
_emit({"created": args.name, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_install_links(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.get_installation_links(args.package, args.company),
|
||||
args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_scan(client, args):
|
||||
for t in args.targets:
|
||||
if not _require_oid(t, "scan target"):
|
||||
return 2
|
||||
if not _gated(f"start a type-{args.type} scan on {len(args.targets)} "
|
||||
f"target(s): {','.join(args.targets)}", args.confirm):
|
||||
return 3
|
||||
result = client.create_scan_task(
|
||||
target_ids=args.targets, scan_type=args.type, name=args.name
|
||||
)
|
||||
_emit({"scanTask": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_move(client, args):
|
||||
for e in args.endpoints:
|
||||
if not _require_oid(e, "endpoint"):
|
||||
return 2
|
||||
if not _require_oid(args.group, "group"):
|
||||
return 2
|
||||
if not _gated(f"move {len(args.endpoints)} endpoint(s) into group "
|
||||
f"{args.group} (changes inherited policy): "
|
||||
f"{','.join(args.endpoints)}", args.confirm):
|
||||
return 3
|
||||
result = client.move_endpoints(args.endpoints, args.group)
|
||||
_emit({"moved": args.endpoints, "to": args.group, "result": result},
|
||||
args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def _print_tags(tags) -> None:
|
||||
@@ -688,6 +758,8 @@ def cmd_endpoint_tags(client, args):
|
||||
|
||||
|
||||
def cmd_set_label(client, args):
|
||||
if not _require_oid(args.endpoint, "endpoint"):
|
||||
return 2
|
||||
if not _gated(f"label endpoint {args.endpoint} = '{args.label}'", args.confirm):
|
||||
return 3
|
||||
result = client.set_endpoint_label(args.endpoint, args.label)
|
||||
@@ -700,6 +772,9 @@ def cmd_reconfigure(client, args):
|
||||
extra, rc = _load_json_arg(args.extra_json, "extra-json")
|
||||
if rc:
|
||||
return rc
|
||||
for t in args.targets:
|
||||
if not _require_oid(t, "target"):
|
||||
return 2
|
||||
if not _gated(f"reconfigure {len(args.targets)} agent(s): {','.join(args.targets)}",
|
||||
args.confirm):
|
||||
return 3
|
||||
@@ -709,14 +784,24 @@ def cmd_reconfigure(client, args):
|
||||
|
||||
|
||||
def cmd_make_group(client, args):
|
||||
if args.parent and not _require_oid(args.parent, "parent group"):
|
||||
return 2
|
||||
if not _gated(f"create custom group '{args.name}'"
|
||||
+ (f" under parent {args.parent}" if args.parent else ""),
|
||||
args.confirm):
|
||||
return 3
|
||||
result = client.create_custom_group(args.name, args.parent)
|
||||
_emit({"createdGroup": args.name, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
# Substrings that mark a JSON-RPC method as state-destroying. `raw` can reach
|
||||
# any method (incl. UNVERIFIED ones), so gate these behind --confirm too.
|
||||
# isolate / blocklist add+remove are NEW destructive verbs from the incidents
|
||||
# (EDR) module - gate them in `raw` as well as via the dedicated subcommands.
|
||||
# Substrings that mark a JSON-RPC method as state-changing. `raw` can reach ANY
|
||||
# method (incl. UNVERIFIED ones), so gate these behind --confirm too. This is a
|
||||
# BEST-EFFORT denylist, not a guarantee: a state-changing method whose name
|
||||
# matches none of these substrings can still run via `raw` - the operator must
|
||||
# verify any `raw` method themselves before passing --confirm.
|
||||
# isolate / blocklist add+remove are destructive verbs from the incidents (EDR)
|
||||
# module - gated in `raw` as well as via the dedicated subcommands.
|
||||
DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
"createreconfigure", "isolat", "addtoblocklist",
|
||||
"removefromblocklist", "assignpolicy",
|
||||
@@ -724,7 +809,13 @@ DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
"configurenotif", "createcompany", "suspendcompany",
|
||||
"activatecompany", "setendpointlabel", "createreport",
|
||||
"createrestore", "createcustomrule", "changeincident",
|
||||
"updateincident", "sendtestpush")
|
||||
"updateincident", "sendtestpush",
|
||||
# state-changing methods also exposed as gated
|
||||
# subcommands - keep them gated via `raw` too.
|
||||
"moveendpoints", "movecustomgroup", "createscan",
|
||||
"createpackage", "createcustomgroup",
|
||||
# agent-deploy task reachable via raw (state-changing)
|
||||
"createinstall")
|
||||
|
||||
|
||||
def _is_destructive_method(method: str) -> bool:
|
||||
@@ -750,16 +841,38 @@ def cmd_raw(client, args):
|
||||
return 0
|
||||
|
||||
|
||||
# --- input validation ---------------------------------------------------------
|
||||
# GravityZone object IDs are 24-char hex (Mongo ObjectId). Validating client-side
|
||||
# stops a malformed/empty id from hitting the live tenant AND from being mislogged
|
||||
# as a functional skill error (the API's "Expected format: 24-char hex ID" reply
|
||||
# matches none of the expected-error markers, so it would otherwise land in
|
||||
# errorlog.md as noise - the bulk of the 2026-06-21 bitdefender entries).
|
||||
_OID_RE = re.compile(r"^[0-9a-fA-F]{24}$")
|
||||
|
||||
|
||||
def _require_oid(value, label: str) -> bool:
|
||||
"""True if `value` is a valid 24-char hex id; else print [ERROR] and False.
|
||||
The caller should `return 2` (user-input error) and must NOT log it."""
|
||||
if value and _OID_RE.match(str(value)):
|
||||
return True
|
||||
print(f"[ERROR] {label} '{value}' is not a valid 24-char hex GravityZone id.",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
# --- destructive (gated) ------------------------------------------------------
|
||||
def _gated(action_desc: str, confirm: bool) -> bool:
|
||||
if not confirm:
|
||||
print("[WARNING] Refusing destructive action without --confirm.")
|
||||
print(f"[INFO] Would: {action_desc}")
|
||||
print("[WARNING] Refusing destructive action without --confirm.",
|
||||
file=sys.stderr)
|
||||
print(f"[INFO] Would: {action_desc}", file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cmd_delete_endpoint(client, args):
|
||||
if not _require_oid(args.endpoint_id, "endpoint"):
|
||||
return 2
|
||||
if not _gated(f"delete endpoint {args.endpoint_id}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_endpoint(args.endpoint_id)
|
||||
@@ -777,6 +890,8 @@ def cmd_delete_package(client, args):
|
||||
|
||||
|
||||
def cmd_delete_group(client, args):
|
||||
if not _require_oid(args.group, "group"):
|
||||
return 2
|
||||
if not _gated(f"delete custom group {args.group}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_custom_group(args.group)
|
||||
@@ -786,6 +901,9 @@ def cmd_delete_group(client, args):
|
||||
|
||||
# --- EDR / incident response (gated) ------------------------------------------
|
||||
def cmd_isolate(client, args):
|
||||
for e in args.endpoints:
|
||||
if not _require_oid(e, "endpoint"):
|
||||
return 2
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"isolate endpoints {targets}", args.confirm):
|
||||
return 3
|
||||
@@ -795,6 +913,9 @@ def cmd_isolate(client, args):
|
||||
|
||||
|
||||
def cmd_unisolate(client, args):
|
||||
for e in args.endpoints:
|
||||
if not _require_oid(e, "endpoint"):
|
||||
return 2
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"restore endpoints from isolation {targets}", args.confirm):
|
||||
return 3
|
||||
@@ -804,6 +925,8 @@ def cmd_unisolate(client, args):
|
||||
|
||||
|
||||
def cmd_blocklist_add(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
desc = (f"add {len(args.hashes)} hash(es) to blocklist for company "
|
||||
f"{args.company}: {','.join(args.hashes)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
@@ -886,7 +1009,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("endpoint_id")
|
||||
|
||||
sp = sub.add_parser("sweep", help="Live security posture sweep.", parents=[common])
|
||||
sp.add_argument("--company", help="Parent id (defaults to ACG container).")
|
||||
sp.add_argument("--company",
|
||||
help="Parent company id. Omit to sweep ALL client companies "
|
||||
"(many live API calls).")
|
||||
|
||||
sub.add_parser("policies", help="List policies (id + name).", parents=[common])
|
||||
sp = sub.add_parser("policy",
|
||||
@@ -962,31 +1087,37 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parents=[common])
|
||||
sp.add_argument("--refresh", action="store_true", help="Force a full re-pull.")
|
||||
|
||||
sp = sub.add_parser("create-package", help="Create an installer package.",
|
||||
sp = sub.add_parser("create-package", help="Create an installer package (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--name", required=True)
|
||||
sp.add_argument("--company")
|
||||
sp.add_argument("--description")
|
||||
sp.add_argument("--language")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("install-links", help="Get installer download URLs.",
|
||||
parents=[common])
|
||||
sp.add_argument("--package", required=True)
|
||||
sp.add_argument("--company")
|
||||
|
||||
sp = sub.add_parser("scan", help="Create a scan task.", parents=[common])
|
||||
sp = sub.add_parser("scan", help="Create a scan task (gated).", parents=[common])
|
||||
sp.add_argument("--targets", nargs="+", required=True)
|
||||
sp.add_argument("--type", type=int, required=True,
|
||||
help="1=Quick 2=Full 3=Memory 4=Custom (verify in console).")
|
||||
sp.add_argument("--name")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("move", help="Move endpoints into a group.", parents=[common])
|
||||
sp = sub.add_parser("move", help="Move endpoints into a group (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--endpoints", nargs="+", required=True)
|
||||
sp.add_argument("--group", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("make-group", help="Create a custom group.", parents=[common])
|
||||
sp = sub.add_parser("make-group", help="Create a custom group (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--name", required=True)
|
||||
sp.add_argument("--parent")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sub.add_parser("endpoint-tags", help="List endpoint tags.", parents=[common])
|
||||
|
||||
@@ -1009,8 +1140,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--method", required=True)
|
||||
sp.add_argument("--params", default="{}", help="JSON object of params.")
|
||||
sp.add_argument("--confirm", action="store_true",
|
||||
help="Required for destructive methods (delete/uninstall/"
|
||||
"remove/reconfigure).")
|
||||
help="Required for methods matching the best-effort destructive "
|
||||
"denylist (delete/uninstall/remove/reconfigure/isolate/"
|
||||
"blocklist/assign/create*/move/...); verify any raw method "
|
||||
"yourself - the denylist is not exhaustive.")
|
||||
|
||||
# destructive (gated)
|
||||
sp = sub.add_parser("delete-endpoint", help="Delete an endpoint (gated).",
|
||||
@@ -1242,6 +1375,7 @@ HANDLERS = {
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
handler = HANDLERS[args.command]
|
||||
client = None
|
||||
try:
|
||||
client = GravityZoneClient()
|
||||
rc = handler(client, args)
|
||||
@@ -1254,6 +1388,9 @@ def main(argv=None) -> int:
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,20 +12,28 @@ Credentials: never hardcoded. Loaded at runtime from the SOPS vault, or from
|
||||
the GRAVITYZONE_API_KEY env var (testing override).
|
||||
|
||||
Cache: only the IDENTITY/STRUCTURE tier is cached (company/endpoint/policy
|
||||
id<->name maps, package list). Volatile status (infected, lastSeen, online,
|
||||
signature freshness) is NEVER cached and always pulled live.
|
||||
id<->name maps + endpoint fqdn, package list). No secrets are ever cached;
|
||||
infra identifiers (hostnames/FQDNs) are. Volatile status (infected, lastSeen,
|
||||
online, signature freshness) is NEVER cached and always pulled live. The cache
|
||||
dir is gitignored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -44,6 +52,58 @@ except ImportError: # pragma: no cover - depends on environment
|
||||
# other data - bound the blast radius rather than echo full bodies into logs.
|
||||
ERROR_BODY_MAX_CHARS = 500
|
||||
|
||||
# --- transient-failure retry policy -------------------------------------------
|
||||
# The live tenant is rate-limited (real HTTP 429s observed during sweeps, which
|
||||
# fan out one getManagedEndpointDetails per endpoint across every company). Retry
|
||||
# 429/5xx/timeout with bounded exponential backoff, honoring Retry-After.
|
||||
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
||||
RETRY_MAX_ATTEMPTS = 4 # total tries = 1 initial + up to (MAX-1) retries
|
||||
RETRY_BASE_DELAY_SECONDS = 1.0
|
||||
RETRY_MAX_DELAY_SECONDS = 30.0 # cap on the exponential backoff
|
||||
RETRY_AFTER_MAX_SECONDS = 120.0 # higher cap for a server-mandated Retry-After
|
||||
|
||||
|
||||
class _RetryableHTTP(Exception):
|
||||
"""Internal signal that a request failed transiently and may be retried.
|
||||
`code` is the HTTP status (int) or a string: 'timeout' (ambiguous - may have
|
||||
reached the server) or 'connect' (pre-send - no side effect, always safe)."""
|
||||
|
||||
def __init__(self, code, headers=None, detail=""):
|
||||
self.code = code
|
||||
self.headers = headers or {}
|
||||
self.detail = detail
|
||||
super().__init__(f"transient {code}")
|
||||
|
||||
|
||||
def _retry_delay(headers, attempt: int) -> float:
|
||||
"""Seconds to wait before the next retry: honor a Retry-After header when
|
||||
present (numeric seconds or an HTTP-date), else exponential backoff + jitter."""
|
||||
ra = None
|
||||
try:
|
||||
ra = headers.get("Retry-After") or headers.get("retry-after")
|
||||
except AttributeError:
|
||||
ra = None
|
||||
if ra:
|
||||
# An explicit server-mandated Retry-After is honored up to a HIGHER cap
|
||||
# than the exponential backoff (don't retry early into another 429).
|
||||
try:
|
||||
# clamp to [0, ceiling]: a malformed negative Retry-After must not
|
||||
# reach time.sleep() (which raises ValueError on a negative value).
|
||||
return min(max(float(ra), 0.0), RETRY_AFTER_MAX_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
dt = parsedate_to_datetime(ra)
|
||||
if dt is not None:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
delta = (dt - datetime.now(timezone.utc)).total_seconds()
|
||||
if delta > 0:
|
||||
return min(delta, RETRY_AFTER_MAX_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
backoff = min(RETRY_BASE_DELAY_SECONDS * (2 ** attempt), RETRY_MAX_DELAY_SECONDS)
|
||||
return backoff + random.uniform(0.0, backoff * 0.25)
|
||||
|
||||
# --- constants ----------------------------------------------------------------
|
||||
GRAVITYZONE_API_BASE_URL = os.environ.get(
|
||||
"GRAVITYZONE_API_BASE_URL",
|
||||
@@ -52,6 +112,10 @@ GRAVITYZONE_API_BASE_URL = os.environ.get(
|
||||
GRAVITYZONE_TIMEOUT_SECONDS = 60.0
|
||||
GRAVITYZONE_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Hard ceiling on paginated loops: a misbehaving API that always returns a full
|
||||
# page must never spin forever (the sweep also fans out N detail calls per page).
|
||||
MAX_PAGINATION_PAGES = 1000
|
||||
|
||||
ACG_ROOT_COMPANY_ID = "5c4280716c0318f3478b456a"
|
||||
ACG_COMPANIES_CONTAINER_ID = "5c4280716c0318f3478b456e"
|
||||
|
||||
@@ -62,6 +126,11 @@ CACHE_TTL_SECONDS = 86400
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
CACHE_DIR = SKILL_DIR / ".cache"
|
||||
CACHE_FILE = CACHE_DIR / "inventory.json"
|
||||
CACHE_LOCK_FILE = CACHE_DIR / "inventory.lock"
|
||||
# Best-effort advisory lock for read-modify-write of the cache. Short timeout:
|
||||
# losing a write-through update is acceptable; hanging the CLI is not.
|
||||
CACHE_LOCK_TIMEOUT_SECONDS = 5.0
|
||||
CACHE_LOCK_STALE_SECONDS = 30.0
|
||||
|
||||
|
||||
class GravityZoneError(RuntimeError):
|
||||
@@ -169,6 +238,30 @@ class GravityZoneClient:
|
||||
self._api_key = api_key # lazily loaded if None
|
||||
self.timeout = timeout
|
||||
self.connect_timeout = connect_timeout
|
||||
self._httpx_client = None # reused across calls (pooling) when httpx present
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the pooled httpx client, if one was opened."""
|
||||
if self._httpx_client is not None:
|
||||
try:
|
||||
self._httpx_client.close()
|
||||
finally:
|
||||
self._httpx_client = None
|
||||
|
||||
def __enter__(self) -> "GravityZoneClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
@property
|
||||
def _client(self):
|
||||
"""Lazily create and reuse a single httpx.Client so a multi-call sweep
|
||||
shares one connection pool instead of a TLS handshake per request."""
|
||||
if self._httpx_client is None:
|
||||
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||
self._httpx_client = httpx.Client(timeout=timeout)
|
||||
return self._httpx_client
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
@@ -181,7 +274,12 @@ class GravityZoneClient:
|
||||
"""Make one JSON-RPC call. Returns body['result'] or raises GravityZoneError."""
|
||||
url = f"{self.api_base_url}/{module}"
|
||||
payload = {"id": "1", "jsonrpc": "2.0", "method": method, "params": params}
|
||||
body = self._post(url, payload)
|
||||
# Read methods (get*/list*) are safe to retry on timeout/5xx; state-changing
|
||||
# methods are NOT (a timeout can fire after the server committed -> a retry
|
||||
# would double-execute, e.g. createScanTask/createPackage). 429 is a
|
||||
# pre-processing rate-limit reject and is always safe (handled in _post).
|
||||
idempotent = method.lower().startswith(("get", "list"))
|
||||
body = self._post(url, payload, idempotent=idempotent)
|
||||
|
||||
if isinstance(body, dict) and "error" in body and body["error"] is not None:
|
||||
err = body["error"]
|
||||
@@ -195,25 +293,65 @@ class GravityZoneClient:
|
||||
return body.get("result")
|
||||
return body
|
||||
|
||||
def _post(self, url: str, payload: dict) -> Any:
|
||||
def _post(self, url: str, payload: dict, idempotent: bool = False) -> Any:
|
||||
"""POST with bounded retry on transient failures. Failures with NO
|
||||
possible side effect (429 pre-processing reject, 'connect' pre-send
|
||||
failure) are always retried; an ambiguous timeout/5xx (may have committed
|
||||
server-side) is retried ONLY for idempotent (read) calls, so a
|
||||
non-idempotent write is never silently re-executed."""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||
try:
|
||||
return self._post_once(url, data)
|
||||
except _RetryableHTTP as exc:
|
||||
retry_safe = (exc.code in (429, "connect")) or idempotent
|
||||
if not retry_safe or attempt >= RETRY_MAX_ATTEMPTS - 1:
|
||||
note = "" if retry_safe else " (non-idempotent; not retried)"
|
||||
raise GravityZoneError(
|
||||
f"GravityZone HTTP {exc.code}{note}: {exc.detail}".rstrip(": ")
|
||||
) from exc
|
||||
delay = _retry_delay(exc.headers, attempt)
|
||||
print(
|
||||
f"[WARNING] GravityZone {exc.code} - retry "
|
||||
f"{attempt + 1}/{RETRY_MAX_ATTEMPTS - 1} in {delay:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(delay)
|
||||
# unreachable: the loop either returns or raises on the final attempt
|
||||
raise GravityZoneError("GravityZone request failed: retries exhausted")
|
||||
|
||||
def _post_once(self, url: str, data: bytes) -> Any:
|
||||
"""One POST. Returns parsed JSON, raises _RetryableHTTP on a transient
|
||||
failure, or GravityZoneError on a terminal one."""
|
||||
if _HAS_HTTPX:
|
||||
try:
|
||||
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(url, content=data, auth=(self.api_key, ""),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = self._client.post(
|
||||
url, content=data, auth=(self.api_key, ""),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
||||
# Pre-send: the connection never established -> no side effect,
|
||||
# always safe to retry (must precede TimeoutException since
|
||||
# ConnectTimeout subclasses it).
|
||||
raise _RetryableHTTP("connect", detail=str(exc)) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
raise GravityZoneError(f"GravityZone request timed out: {exc}") from exc
|
||||
raise _RetryableHTTP("timeout", detail=str(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
code = exc.response.status_code
|
||||
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
if code in RETRY_STATUSES:
|
||||
raise _RetryableHTTP(code, exc.response.headers, detail) from exc
|
||||
raise GravityZoneError(
|
||||
f"GravityZone HTTP {exc.response.status_code}: {detail}"
|
||||
) from exc
|
||||
f"GravityZone HTTP {code}: {detail}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise GravityZoneError(f"GravityZone request failed: {exc}") from exc
|
||||
raise GravityZoneError(
|
||||
f"GravityZone request failed: {exc}") from exc
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
body = (resp.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
raise GravityZoneError(
|
||||
f"GravityZone returned a non-JSON response: {body}") from exc
|
||||
|
||||
# stdlib fallback
|
||||
token = base64.b64encode(f"{self.api_key}:".encode("utf-8")).decode("ascii")
|
||||
@@ -229,12 +367,32 @@ class GravityZoneClient:
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
if exc.code in RETRY_STATUSES:
|
||||
raise _RetryableHTTP(exc.code, getattr(exc, "headers", None),
|
||||
detail) from exc
|
||||
raise GravityZoneError(f"GravityZone HTTP {exc.code}: {detail}") from exc
|
||||
except TimeoutError as exc:
|
||||
raise _RetryableHTTP("timeout", detail=str(exc)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise GravityZoneError(f"GravityZone request failed: {exc}") from exc
|
||||
# Classify conservatively: only a KNOWN pre-send failure (connection
|
||||
# refused / DNS failure) is the always-safe 'connect'. Anything else -
|
||||
# a connect/read timeout, or an ambiguous post-send reset like
|
||||
# RemoteDisconnected/ConnectionResetError that urllib also wraps in
|
||||
# URLError - is 'timeout' so a non-idempotent write is NOT retried.
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, (ConnectionRefusedError, socket.gaierror)):
|
||||
code = "connect"
|
||||
else:
|
||||
code = "timeout"
|
||||
raise _RetryableHTTP(code, detail=str(exc)) from exc
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
body = raw.decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
raise GravityZoneError(
|
||||
f"GravityZone returned a non-JSON response: {body}") from exc
|
||||
|
||||
# ======================================================================
|
||||
# READ METHODS (always live)
|
||||
@@ -322,6 +480,31 @@ class GravityZoneClient:
|
||||
items = [i for i in result.get("items", []) if i.get("type") == 1]
|
||||
return {"total": len(items), "items": items}
|
||||
|
||||
def list_all_companies(self) -> list[dict]:
|
||||
"""Every company under the ACG container, paginated (type==1 only).
|
||||
list_companies() returns only one page; callers that must see the WHOLE
|
||||
fleet (sweep-all, inventory refresh) use this so a >100-company tenant is
|
||||
not silently truncated."""
|
||||
companies: list[dict] = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
while True:
|
||||
result = self._jsonrpc_request(
|
||||
"network", "getNetworkInventoryItems",
|
||||
{"parentId": ACG_COMPANIES_CONTAINER_ID,
|
||||
"page": page, "perPage": per_page},
|
||||
) or {}
|
||||
raw = result.get("items", [])
|
||||
companies.extend(i for i in raw if i.get("type") == 1)
|
||||
if len(raw) < per_page: # short raw page = last page
|
||||
break
|
||||
page += 1
|
||||
if page > MAX_PAGINATION_PAGES:
|
||||
print(f"[WARNING] list_all_companies hit the {MAX_PAGINATION_PAGES}-"
|
||||
"page ceiling; companies may be truncated.", file=sys.stderr)
|
||||
break
|
||||
return companies
|
||||
|
||||
def list_endpoints(
|
||||
self, parent_id: Optional[str] = None, page: int = 1, per_page: int = 100
|
||||
) -> dict:
|
||||
@@ -399,10 +582,16 @@ class GravityZoneClient:
|
||||
)
|
||||
)
|
||||
|
||||
total = data.get("total", 0)
|
||||
if page * per_page >= total:
|
||||
# Stop at the last page. A short page (< per_page) means no more;
|
||||
# do NOT rely on `total` - some responses omit it, which would
|
||||
# truncate the sweep after page 1.
|
||||
if len(items) < per_page:
|
||||
break
|
||||
page += 1
|
||||
if page > MAX_PAGINATION_PAGES:
|
||||
print(f"[WARNING] security_sweep hit the {MAX_PAGINATION_PAGES}-page "
|
||||
"ceiling; results may be truncated.", file=sys.stderr)
|
||||
break
|
||||
|
||||
summaries.sort(
|
||||
key=lambda s: (
|
||||
@@ -418,7 +607,7 @@ class GravityZoneClient:
|
||||
"""Sweep every client company. The companies container is NOT a valid
|
||||
endpoint parent, so iterate each company and sweep it individually."""
|
||||
summaries: list[GZEndpointSummary] = []
|
||||
companies = self.list_companies(per_page=100).get("items", [])
|
||||
companies = self.list_all_companies()
|
||||
for company in companies:
|
||||
cid = company.get("id")
|
||||
if not cid:
|
||||
@@ -610,11 +799,11 @@ class GravityZoneClient:
|
||||
def isolate_endpoints(self, endpoint_ids: list[str]) -> Any:
|
||||
"""Isolate endpoints from the network (incidents.createIsolateEndpointTask).
|
||||
|
||||
v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of
|
||||
task ids. STATE-CHANGING - gate behind --confirm at the call site.
|
||||
VERIFIED LIVE 2026-06-21: the API takes a SINGLE `endpointId` per call
|
||||
(an `endpointIds` array errors "not expected"), so we loop. Returns the
|
||||
single task result for one id, else a list. STATE-CHANGING - gate behind
|
||||
--confirm at the call site.
|
||||
"""
|
||||
# VERIFIED LIVE 2026-06-21: the API takes a SINGLE `endpointId` per call
|
||||
# (NOT an `endpointIds` array - that errors "not expected"). Loop for many.
|
||||
results = []
|
||||
for eid in endpoint_ids:
|
||||
results.append(self._jsonrpc_request(
|
||||
@@ -625,11 +814,11 @@ class GravityZoneClient:
|
||||
def restore_endpoints_from_isolation(self, endpoint_ids: list[str]) -> Any:
|
||||
"""Un-isolate endpoints (incidents.createRestoreEndpointFromIsolationTask).
|
||||
|
||||
v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of
|
||||
task ids. STATE-CHANGING - gate behind --confirm at the call site.
|
||||
VERIFIED LIVE 2026-06-21: a SINGLE `endpointId` per call (not an array),
|
||||
so we loop. Returns the single task result for one id, else a list.
|
||||
Note: fails if the isolation task is still in progress - wait + retry.
|
||||
STATE-CHANGING - gate behind --confirm at the call site.
|
||||
"""
|
||||
# VERIFIED LIVE 2026-06-21: single `endpointId` per call (not an array).
|
||||
# Note: fails if the isolation task is still in progress - wait + retry.
|
||||
results = []
|
||||
for eid in endpoint_ids:
|
||||
results.append(self._jsonrpc_request(
|
||||
@@ -1039,10 +1228,60 @@ class GravityZoneClient:
|
||||
return None
|
||||
|
||||
def _write_cache(self, cache: dict) -> None:
|
||||
"""Atomically replace the cache file (temp write + os.replace) so a crash
|
||||
mid-write or a concurrent reader can never see a truncated file."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE.write_text(
|
||||
json.dumps(cache, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
payload = json.dumps(cache, indent=2, sort_keys=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(CACHE_DIR), prefix=".inventory.",
|
||||
suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, CACHE_FILE) # atomic on the same filesystem
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _cache_lock(self):
|
||||
"""Best-effort cross-platform advisory lock around a read-modify-write of
|
||||
the cache, so two concurrent gz.py invocations don't lose each other's
|
||||
write-through update. Steals a stale lock; on timeout proceeds unlocked
|
||||
(a lost update is tolerable, a hang is not)."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + CACHE_LOCK_TIMEOUT_SECONDS
|
||||
acquired = False
|
||||
while True:
|
||||
try:
|
||||
fd = os.open(str(CACHE_LOCK_FILE),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(fd)
|
||||
acquired = True
|
||||
break
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - os.path.getmtime(CACHE_LOCK_FILE)
|
||||
if age > CACHE_LOCK_STALE_SECONDS:
|
||||
os.unlink(CACHE_LOCK_FILE)
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
break # give up the lock, proceed unlocked
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
os.unlink(CACHE_LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _cache_is_fresh(self, cache: dict) -> bool:
|
||||
fetched = cache.get("fetched_at")
|
||||
@@ -1064,7 +1303,7 @@ class GravityZoneClient:
|
||||
endpoints_map: dict[str, dict] = {}
|
||||
policies_map: dict[str, str] = {}
|
||||
|
||||
companies = self.list_companies(per_page=100).get("items", [])
|
||||
companies = self.list_all_companies()
|
||||
for c in companies:
|
||||
cid = c.get("id")
|
||||
if cid:
|
||||
@@ -1090,10 +1329,15 @@ class GravityZoneClient:
|
||||
"company_id": ep.get("companyId", cid),
|
||||
"fqdn": ep.get("fqdn") or ep.get("FQDN") or "",
|
||||
}
|
||||
total = data.get("total", 0)
|
||||
if page * 100 >= total:
|
||||
# Last page = a short page; don't rely on `total` (may be omitted).
|
||||
if len(items) < 100:
|
||||
break
|
||||
page += 1
|
||||
if page > MAX_PAGINATION_PAGES:
|
||||
print("[WARNING] refresh_inventory hit the "
|
||||
f"{MAX_PAGINATION_PAGES}-page ceiling for company "
|
||||
f"{cid}; inventory may be truncated.", file=sys.stderr)
|
||||
break
|
||||
|
||||
try:
|
||||
for p in self.list_policies(per_page=100).get("items", []):
|
||||
@@ -1129,27 +1373,39 @@ class GravityZoneClient:
|
||||
return self.refresh_inventory()
|
||||
|
||||
def _cache_add_group(self, group_id: str, name: str) -> None:
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return # no cache yet - next refresh picks it up
|
||||
cache.setdefault("companies", {})
|
||||
# Groups live in the inventory tree; store under a 'groups' map.
|
||||
cache.setdefault("groups", {})[group_id] = name
|
||||
self._write_cache(cache)
|
||||
# Best-effort: the cache is only a hint, so a write failure must NEVER
|
||||
# turn a successful API mutation (createCustomGroup) into a reported error.
|
||||
try:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return # no cache yet - next refresh picks it up
|
||||
# Groups live in the inventory tree; store under a 'groups' map.
|
||||
cache.setdefault("groups", {})[group_id] = name
|
||||
self._write_cache(cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cache_add_package(self, package_name: str, create_result: Any) -> None:
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return
|
||||
packages = cache.setdefault("packages", [])
|
||||
pkg_id = create_result if isinstance(create_result, str) else None
|
||||
if isinstance(create_result, dict):
|
||||
pkg_id = create_result.get("id")
|
||||
if not any(
|
||||
(isinstance(p, dict) and p.get("name") == package_name) for p in packages
|
||||
):
|
||||
packages.append({"id": pkg_id, "name": package_name})
|
||||
self._write_cache(cache)
|
||||
# Best-effort (see _cache_add_group): never let a cache failure mask a
|
||||
# successful createPackage.
|
||||
try:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return
|
||||
packages = cache.setdefault("packages", [])
|
||||
pkg_id = create_result if isinstance(create_result, str) else None
|
||||
if isinstance(create_result, dict):
|
||||
pkg_id = create_result.get("id")
|
||||
if not any(
|
||||
(isinstance(p, dict) and p.get("name") == package_name)
|
||||
for p in packages
|
||||
):
|
||||
packages.append({"id": pkg_id, "name": package_name})
|
||||
self._write_cache(cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -16,6 +16,10 @@ import sys
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GZ = os.path.join(HERE, "gz.py")
|
||||
ACG = "5c428b246c031893678b4569" # ACG internal company (real)
|
||||
# Valid-FORMAT (24-char hex) placeholder id. Non-existent on the tenant but it
|
||||
# passes client-side _require_oid so gate-refusal tests reach the gate (rc3)
|
||||
# instead of short-circuiting on id validation (rc2). Never used with --confirm.
|
||||
VID = "0123456789abcdef01234567"
|
||||
|
||||
results = []
|
||||
|
||||
@@ -61,7 +65,7 @@ check("companies table", ["companies"], want_rc=0, out_has="Companies:")
|
||||
check("companies json", ["companies", "--json"], want_rc=0, out_json_ok=True)
|
||||
check("endpoints (real co)", ["endpoints", "--company", ACG], want_rc=0, out_has="Endpoints:")
|
||||
check("endpoints json", ["endpoints", "--company", ACG, "--json"], want_rc=0, out_json_ok=True)
|
||||
check("endpoints bogus parent -> err", ["endpoints", "--company", "bogus"], want_rc=1, err_has="[ERROR]")
|
||||
check("endpoints bogus parent -> rc2 (client-side id validation)", ["endpoints", "--company", "bogus"], want_rc=2, err_has="[ERROR]")
|
||||
check("policies", ["policies"], want_rc=0, out_has="Policies:")
|
||||
check("packages", ["packages"], want_rc=0, out_has="Packages:")
|
||||
check("quarantine (real co)", ["quarantine", "--company", ACG], want_rc=0, out_has="Quarantine items:")
|
||||
@@ -84,26 +88,34 @@ check("push-stats (unconfigured -> rc0)", ["push-stats"], want_rc=0, out_has="no
|
||||
# policy detail must NOT carry the old false 'shallow' warning anymore
|
||||
check("policy no shallow warning", ["policy", "5c42940b6e16d61a0c8b4568"], want_rc=0, err_has=None)
|
||||
|
||||
# --- error handling: a MALFORMED id (not valid hex/ObjectId) makes the API
|
||||
# error, which must exit non-zero (1). Note: a well-formed but non-existent
|
||||
# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- that is the
|
||||
# API's behavior, not a skill bug, so we test with a malformed value here. ---
|
||||
check("endpoint bad id -> rc1", ["endpoint", "bogus"], want_rc=1, err_has="[ERROR]")
|
||||
check("policy bad id -> rc1", ["policy", "bogus"], want_rc=1, err_has="[ERROR]")
|
||||
# --- error handling: a MALFORMED id (not 24-char hex ObjectId) is now caught
|
||||
# CLIENT-SIDE by _require_oid and exits rc2 BEFORE any API call (so it neither
|
||||
# hits the live tenant nor pollutes errorlog.md). A well-formed but non-existent
|
||||
# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- the API's
|
||||
# behavior, not a skill bug. ---
|
||||
check("endpoint bad id -> rc2 (client-side)", ["endpoint", "bogus"], want_rc=2, err_has="not a valid")
|
||||
check("policy bad id -> rc2 (client-side)", ["policy", "bogus"], want_rc=2, err_has="not a valid")
|
||||
check("sweep bad company -> rc2 (client-side)", ["sweep", "--company", "bogus"], want_rc=2, err_has="not a valid")
|
||||
check("install-links bad company -> rc2", ["install-links", "--package", "P", "--company", "bogus"], want_rc=2, err_has="not a valid")
|
||||
|
||||
# --- argparse: missing required arg -> rc2 ---
|
||||
check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2)
|
||||
check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2)
|
||||
|
||||
# --- gating: destructive without --confirm -> rc3, no API call ---
|
||||
check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, out_has="Would")
|
||||
check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", "x"], want_rc=3)
|
||||
# --- gating: destructive without --confirm -> rc3, no API call (valid-format ids
|
||||
# so the gate is reached; id validation is tested separately below) ---
|
||||
check("isolate no confirm -> rc3", ["isolate", "--endpoints", VID], want_rc=3, err_has="Would")
|
||||
check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", VID], want_rc=3)
|
||||
check("blocklist-add no confirm -> rc3", ["blocklist-add", "--company", ACG, "--hashes", "abc"], want_rc=3)
|
||||
check("blocklist-remove no confirm -> rc3", ["blocklist-remove", "--id", "x"], want_rc=3)
|
||||
check("delete-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3)
|
||||
check("delete-endpoint no confirm -> rc3", ["delete-endpoint", VID], want_rc=3)
|
||||
check("delete-package no confirm -> rc3", ["delete-package", "--id", "x"], want_rc=3)
|
||||
check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3)
|
||||
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", "p", "--targets", "x"], want_rc=3, out_has="Would")
|
||||
check("delete-group no confirm -> rc3", ["delete-group", "--group", VID], want_rc=3)
|
||||
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", VID, "--targets", VID], want_rc=3, err_has="Would")
|
||||
# id validation runs BEFORE the gate: a malformed id on a gated cmd -> rc2 (not rc3)
|
||||
check("delete-endpoint bad id -> rc2", ["delete-endpoint", "x"], want_rc=2, err_has="not a valid")
|
||||
check("isolate bad id -> rc2", ["isolate", "--endpoints", "x", "--confirm"], want_rc=2, err_has="not a valid")
|
||||
check("company-delete bad id -> rc2", ["company-delete", "--id", "x", "--confirm"], want_rc=2, err_has="not a valid")
|
||||
check("push-set no confirm -> rc3", ["push-set", "--status", "1", "--url", "https://x/y"], want_rc=3)
|
||||
check("push-set enable no url -> rc2", ["push-set", "--status", "1", "--confirm"], want_rc=2)
|
||||
check("raw assignPolicy no confirm -> rc3", ["raw", "--module", "network", "--method", "assignPolicy", "--params", "{}"], want_rc=3)
|
||||
@@ -122,32 +134,33 @@ check("quarantine-remove no confirm -> rc3", ["quarantine-remove", "--items", "x
|
||||
check("quarantine-restore no confirm -> rc3", ["quarantine-restore", "--items", "x"], want_rc=3)
|
||||
check("custom-rule-create no confirm -> rc3", ["custom-rule-create", "--name", "R"], want_rc=3)
|
||||
check("custom-rule-delete no confirm -> rc3", ["custom-rule-delete", "--id", "x"], want_rc=3)
|
||||
check("incident-status no confirm -> rc3", ["incident-status", "--type", "t", "--set-json", "{}"], want_rc=3)
|
||||
check("incident-note no confirm -> rc3", ["incident-note", "--type", "t", "--set-json", "{}"], want_rc=3)
|
||||
check("incident-status no confirm -> rc3", ["incident-status", "--type", "t", "--set-json", "{\"status\":1}"], want_rc=3)
|
||||
check("incident-status empty set-json -> rc2", ["incident-status", "--type", "t", "--set-json", "{}", "--confirm"], want_rc=2)
|
||||
check("incident-note no confirm -> rc3", ["incident-note", "--type", "t", "--set-json", "{\"text\":\"x\"}"], want_rc=3)
|
||||
check("raw createReport no confirm -> rc3", ["raw", "--module", "reports", "--method", "createReport", "--params", "{}"], want_rc=3)
|
||||
check("raw createCustomRule no confirm -> rc3", ["raw", "--module", "incidents", "--method", "createCustomRule", "--params", "{}"], want_rc=3)
|
||||
|
||||
# --- network completion ---
|
||||
check("endpoint-tags", ["endpoint-tags"], want_rc=0)
|
||||
check("set-label no confirm -> rc3", ["set-label", "--endpoint", "x", "--label", "y"], want_rc=3)
|
||||
check("reconfigure no confirm -> rc3", ["reconfigure", "--targets", "x"], want_rc=3)
|
||||
check("set-label no confirm -> rc3", ["set-label", "--endpoint", VID, "--label", "y"], want_rc=3)
|
||||
check("reconfigure no confirm -> rc3", ["reconfigure", "--targets", VID], want_rc=3)
|
||||
check("raw reconfigure no confirm -> rc3", ["raw", "--module", "network", "--method", "createReconfigureClientTask", "--params", "{}"], want_rc=3)
|
||||
check("raw setEndpointLabel no confirm -> rc3", ["raw", "--module", "network", "--method", "setEndpointLabel", "--params", "{}"], want_rc=3)
|
||||
|
||||
# --- companies module ---
|
||||
check("company (own, no id)", ["company"], want_rc=0)
|
||||
check("company-create no confirm -> rc3", ["company-create", "--type", "1", "--name", "Test Co"], want_rc=3, out_has="Would")
|
||||
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", "x"], want_rc=3)
|
||||
check("company-activate no confirm -> rc3", ["company-activate", "--id", "x"], want_rc=3)
|
||||
check("company-delete no confirm -> rc3", ["company-delete", "--id", "x"], want_rc=3)
|
||||
check("company-create no confirm -> rc3", ["company-create", "--type", "1", "--name", "Test Co"], want_rc=3, err_has="Would")
|
||||
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", VID], want_rc=3)
|
||||
check("company-activate no confirm -> rc3", ["company-activate", "--id", VID], want_rc=3)
|
||||
check("company-delete no confirm -> rc3", ["company-delete", "--id", VID], want_rc=3)
|
||||
check("raw createCompany no confirm -> rc3", ["raw", "--module", "companies", "--method", "createCompany", "--params", "{}"], want_rc=3)
|
||||
|
||||
# --- accounts module ---
|
||||
check("account (own, no id)", ["account"], want_rc=0)
|
||||
check("account-create no confirm -> rc3", ["account-create", "--email", "t@x.io"], want_rc=3, out_has="Would")
|
||||
check("account-update no confirm -> rc3", ["account-update", "--id", "a", "--set-json", "{\"role\":5}"], want_rc=3)
|
||||
check("account-update bad json -> rc2", ["account-update", "--id", "a", "--set-json", "{bad", "--confirm"], want_rc=2)
|
||||
check("account-delete no confirm -> rc3", ["account-delete", "--id", "a"], want_rc=3)
|
||||
check("account-create no confirm -> rc3", ["account-create", "--email", "t@x.io"], want_rc=3, err_has="Would")
|
||||
check("account-update no confirm -> rc3", ["account-update", "--id", VID, "--set-json", "{\"role\":5}"], want_rc=3)
|
||||
check("account-update bad json -> rc2", ["account-update", "--id", VID, "--set-json", "{bad", "--confirm"], want_rc=2)
|
||||
check("account-delete no confirm -> rc3", ["account-delete", "--id", VID], want_rc=3)
|
||||
check("notif-configure no confirm -> rc3", ["notif-configure", "--settings-json", "{\"deleteAfter\":7}"], want_rc=3)
|
||||
check("raw createAccount no confirm -> rc3", ["raw", "--module", "accounts", "--method", "createAccount", "--params", "{}"], want_rc=3)
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
---
|
||||
name: coord
|
||||
description: >
|
||||
Talk to the ClaudeTools coordination API (inter-session messaging, fleet todos,
|
||||
resource locks, component/status) without re-deriving the schema. Send/read messages
|
||||
to another machine's session or BROADCAST to the fleet; create/list/complete coord
|
||||
todos; claim/release work locks; read coord status. Triggers: send a coord message,
|
||||
message <machine>/<user>, broadcast to the fleet, coord todo, claim a lock,
|
||||
coord status, any unread coord messages.
|
||||
description: "Talk to the ClaudeTools coordination API without re-deriving the schema: send/read inter-session messages or fleet broadcasts, coord todos, claim/release locks, coord status. Triggers: send a coord message, broadcast to the fleet, coord todo, claim a lock, coord status."
|
||||
---
|
||||
|
||||
# coord — coordination API helper
|
||||
@@ -27,6 +21,7 @@ bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills
|
||||
| `msg send <to> "<subject>" --body-file <path>` | Same, body from a file (use for long/multi-line bodies — avoids shell-quoting breakage). |
|
||||
| `msg inbox [--all]` | Your unread messages (or `--all`). Print these prominently per the coord rule. |
|
||||
| `msg read <id>` | Mark a message read. |
|
||||
| `msg purge --before YYYY-MM-DD [--to <session>] [--yes]` | Delete dealt-with messages older than the cutoff date. **DRY-RUN by default** (previews what would go); add `--yes` to actually delete. `--to` scopes to one recipient session. Refuses to run without `--before` (can't wipe the store by accident). Destructive + fleet-wide — coordination msgs are ephemeral; the durable record is in session logs. |
|
||||
| `todo add "<text>" [--user U] [--project KEY] [--parent ID] [--source TXT] [--auto]` | Create a todo (auto-fills `created_by_user`/`created_by_machine`). `--text-file` for long text. |
|
||||
| `todo list [--user U] [--project KEY] [--status pending\|done\|all]` | List todos. |
|
||||
| `todo done <id>` | Mark a todo done (sets `completed_by`). |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user