skills: add datto-edr (Datto EDR / Infocyte HUNT) + syncro-rmm memory
New /datto-edr skill — standalone CLI for the Datto EDR REST API (azcomp4587, rebranded Infocyte HUNT, raw-token LoopBack). Live-verified reads across the whole MSP fleet: orgs/sites/agents/detections/sweep + deploy-cmd. Scan/isolate gated behind --confirm (shape-verified, not run against prod). Token vaulted at msp-tools/datto-edr.sops.yaml. Also: reference_syncro_rmm_api_gui_only memory (Syncro RMM policy mgmt is GUI-only) and the guru-rmm submodule pointer bump (Feature 6 EDR research). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.claude/skills/datto-edr/.gitignore
vendored
Normal file
3
.claude/skills/datto-edr/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
153
.claude/skills/datto-edr/SKILL.md
Normal file
153
.claude/skills/datto-edr/SKILL.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: datto-edr
|
||||
description: >-
|
||||
Control the ACG Datto EDR / Datto AV tenant (azcomp4587 — Datto EDR is rebranded
|
||||
Infocyte HUNT; per-tenant LoopBack REST API). Read the whole MSP fleet from ONE
|
||||
token: per-client organizations, sites, agents (online/AV/isolation/version),
|
||||
detections (MITRE-tagged alerts), and a per-client security-posture sweep. Gated
|
||||
actions: trigger scans, invoke response extensions (host isolation), and emit the
|
||||
agent install one-liner for RMM-pushed deployment. Read-only by default; mutating
|
||||
ops require --confirm. Triggers: datto edr, datto av, infocyte, EDR detections,
|
||||
isolate endpoint, run a scan, deploy edr agent, edr security sweep, endpoint
|
||||
detection response, azcomp4587.
|
||||
---
|
||||
|
||||
# Datto EDR (Infocyte HUNT) Skill
|
||||
|
||||
Standalone CLI for the Datto EDR REST API against the live ACG tenant
|
||||
(`azcomp4587.infocyte.com`). Datto EDR is the rebranded **Infocyte HUNT** platform,
|
||||
so the API is a per-tenant **LoopBack** service: `https://<instance>.infocyte.com/api/<Model>`.
|
||||
Read-only by default; mutating operations are gated behind `--confirm`.
|
||||
|
||||
One API token sees the **entire MSP fleet** (13 client orgs, 40 scan groups, 215
|
||||
agents, 1500+ alerts as of build) — segmented per-client by Organization.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
```bash
|
||||
PY="bash $CLAUDETOOLS_ROOT/.claude/scripts/py.sh"
|
||||
EDR="$PY C:/claudetools/.claude/skills/datto-edr/scripts/edr.py"
|
||||
|
||||
$EDR status # tenant rollup
|
||||
$EDR orgs # clients (Organizations)
|
||||
$EDR sites --org <orgId> # client sites (Locations)
|
||||
$EDR agents --org <orgId> # endpoints for a client
|
||||
$EDR detections --org <orgId> --days 7
|
||||
$EDR sweep # per-client posture, sorted by recent detections
|
||||
```
|
||||
|
||||
Transport auto-selects httpx if installed, else stdlib urllib (no hard dependency).
|
||||
|
||||
## Credentials
|
||||
|
||||
The token is NEVER hardcoded. Loaded at runtime from the SOPS vault:
|
||||
|
||||
```
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
|
||||
get-field msp-tools/datto-edr.sops.yaml credentials.api_token
|
||||
```
|
||||
|
||||
**Auth model (critical):** the token is passed in the **raw `Authorization` header
|
||||
— NOT `Bearer <token>`, no prefix.** (Verified live 2026-06-25.) For testing you can
|
||||
override with `DATTO_EDR_TOKEN` and `DATTO_EDR_BASE_URL`. The token **expires ~1 year
|
||||
after creation (~2027-06-25)** — refresh it in the console (username menu → Admin →
|
||||
Users & Tokens → API Tokens) and update the vault entry before it lapses.
|
||||
|
||||
## Data model (how the tenant is structured)
|
||||
|
||||
- **Organization** = a client/company (`orgs`). Carries agentCount/alertCount.
|
||||
- **Location** = a client site (`sites`). Carries `organizationId`. **Agents belong
|
||||
to a Location** via `Agent.locationId`. This is the inventory hierarchy:
|
||||
Organization → Location → Agent.
|
||||
- **Target** = a *scan* target group (`scan-targets`), the scannable unit for the
|
||||
`scan` command (`POST targets/{id}/scan`). Distinct from Location (though they may
|
||||
share an id when a site maps 1:1 to a scan group).
|
||||
- **deviceGroup** = a global category ("Servers", "Workstations") spanning all orgs —
|
||||
NOT a per-client grouping. Not used for client scoping.
|
||||
- **Alert** = a detection. Carries `severity` (0 info … 4 critical), `mitreTactic`/
|
||||
`mitreId`, `hostname`, `organizationName`, `targetGroupName`, `eventTime`.
|
||||
|
||||
## Command surface
|
||||
|
||||
### Reads (run freely — always live, never cached)
|
||||
|
||||
```bash
|
||||
$EDR status # counts: orgs/sites/agents/active/isolated/alerts
|
||||
$EDR orgs # clients + agent/alert/site counts
|
||||
$EDR sites [--org <orgId>] # Locations (client sites)
|
||||
$EDR scan-targets [--org <orgId>] # scan target groups (for `scan`)
|
||||
$EDR agents [--org <orgId>] [--site <locationId>] [--limit N]
|
||||
$EDR agent <agentId> # full agent detail
|
||||
$EDR detections [--org <orgId>] [--site <locationId>] [--severity 0-4] [--days N] [--limit N]
|
||||
$EDR detection <alertId> # full alert detail
|
||||
$EDR sweep [--org <orgId>] # per-client posture rollup (headline view)
|
||||
$EDR extensions # list response/collection extensions
|
||||
$EDR deploy-cmd [--regkey <key>] # emit the agent install one-liner (see Deployment)
|
||||
```
|
||||
|
||||
`agent`/`detection`/`status`/`sweep` support `--json` (place it before the subcommand
|
||||
or after — argparse global flag): `$EDR --json sweep`.
|
||||
|
||||
### Mutating (gated — refuse without `--confirm`)
|
||||
|
||||
```bash
|
||||
# Scan: POST targets/{targetGroupId}/scan (whole group) or POST targets/scan (single)
|
||||
$EDR scan --target-group <id> --confirm
|
||||
$EDR scan --target-group <id> --target <ip/host> --confirm
|
||||
|
||||
# Response extension (host isolation, kill, quarantine) — runs as a scan w/ extension
|
||||
$EDR isolate --target-group <id> --target <host> --extension-name "Host Isolation" --confirm
|
||||
|
||||
# Power tool — any endpoint. Non-GET requires --confirm.
|
||||
$EDR raw GET Agents --filter '{"limit":1}'
|
||||
$EDR raw POST targets/<id>/scan --data '{"options":{...}}' --confirm
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Agent install is **not a REST call** — it's the agent binary run on the endpoint.
|
||||
`deploy-cmd` emits the official one-liner (pulls a live registration key from
|
||||
`/agentKeys`):
|
||||
|
||||
```
|
||||
Install-EDR -InstanceName azcomp4587 -RegKey <key>
|
||||
```
|
||||
|
||||
Push it to a machine via **GuruRMM script execution** (`/rmm`) or any remote-exec
|
||||
channel. `RegKey` auto-approves the agent and adds it to the default target group;
|
||||
without it the agent registers but waits for manual approval in the console.
|
||||
|
||||
## Safety gating
|
||||
|
||||
- Reads never mutate and run without confirmation.
|
||||
- `scan`, `isolate`, and any non-GET `raw` print a `[DRY-RUN]` line and exit non-zero
|
||||
unless `--confirm` is passed.
|
||||
- **Never** run a scan or isolation casually — these hit live client production
|
||||
endpoints. Confirm the target group / host first with the read commands.
|
||||
|
||||
## Verified vs. unverified
|
||||
|
||||
**Live-verified 2026-06-25 (read fully exercised on the tenant):** `status`, `orgs`,
|
||||
`sites`, `scan-targets`, `agents` (incl. per-org via Location mapping), `agent`,
|
||||
`detections`, `detection`, `sweep`, `deploy-cmd` (real key returned), `raw` GET, and
|
||||
the `--confirm` gate.
|
||||
|
||||
**SHAPE-verified, RUN-unverified (NOT executed against prod during build — would
|
||||
scan/isolate real client machines):** `scan`, `isolate`/`run_response_extension`. The
|
||||
payload shapes come from the KaseyaDEDR `InfocyteHUNTAPI` module (`scan.ps1`). Before
|
||||
first real use, confirm the response-extension id/name via `$EDR extensions` and do a
|
||||
single deliberate test on an ACG-internal machine.
|
||||
|
||||
## Relationship to GuruRMM (Feature 6)
|
||||
|
||||
This skill is the **prototype for GuruRMM security-connector #2** (Datto EDR), exactly
|
||||
as the `bitdefender` skill prototyped connector #1. Its read logic (per-client agents +
|
||||
detections + AV/isolation state) ports into the GuruRMM `SecurityVendor` trait; the
|
||||
live "EDR add-on" dashboard is best fed by EDR **webhooks** (Admin → Webhooks) into a
|
||||
GuruRMM receiver, complemented by REST polling here. See
|
||||
`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md` Feature 6.
|
||||
|
||||
## Reference
|
||||
|
||||
Full endpoint map, auth detail, LoopBack filter syntax, and the scan/response payload
|
||||
shapes: `references/api-reference.md`.
|
||||
111
.claude/skills/datto-edr/references/api-reference.md
Normal file
111
.claude/skills/datto-edr/references/api-reference.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Datto EDR (Infocyte HUNT) API reference
|
||||
|
||||
Datto EDR == rebranded Infocyte HUNT. The API is a per-tenant **LoopBack** REST
|
||||
service. Everything below was verified live against `azcomp4587.infocyte.com` on
|
||||
2026-06-25 unless marked otherwise.
|
||||
|
||||
## Base URL & auth
|
||||
|
||||
- Base: `https://<instance>.infocyte.com/api` (this tenant: `azcomp4587`).
|
||||
- Self-documenting LoopBack explorer: `https://<instance>.infocyte.com/explorer`.
|
||||
- **Auth: raw 64-char token in the `Authorization` header.** NO `Bearer` prefix, no
|
||||
Basic, no OAuth. `Authorization: <token>`. (Source: KaseyaDEDR PowershellTools
|
||||
`requestHelpers.ps1`; confirmed live — `Bearer` prefix would fail.)
|
||||
- Token created in console: username menu → **Admin → Users & Tokens → API Tokens →
|
||||
Create new token**. Shown once. **Expires 1 year after creation.**
|
||||
- Vault: `msp-tools/datto-edr.sops.yaml` field `credentials.api_token`.
|
||||
|
||||
## LoopBack conventions
|
||||
|
||||
- Models are PascalCase collections: `Organizations`, `Locations`, `Targets`,
|
||||
`Agents`, `Alerts`, `Boxes`, `Reports`, `deviceGroups` (lowercase), `agentKeys`.
|
||||
- List with a filter: `GET /<Model>?filter=<urlencoded JSON>` where the JSON supports
|
||||
`{"where":{...},"limit":N,"order":"field DIR","fields":{"x":true}}`.
|
||||
- `where` operators: `{"field":{"gt":"..."}}`, `{"field":{"inq":[...]}}`, equality
|
||||
`{"field":val}`.
|
||||
- Count: `GET /<Model>/count?where=<urlencoded JSON>` → `{"count":N}`.
|
||||
- Detail: `GET /<Model>/{id}`.
|
||||
- A model whose route doesn't exist returns `{"error":{"statusCode":404,"message":
|
||||
"There is no method to handle GET /..."}}`.
|
||||
|
||||
## Data hierarchy
|
||||
|
||||
```
|
||||
Organization (client) GET /Organizations [id,name,agentCount,alertCount,locationCount,tenantId]
|
||||
└─ Location (site) GET /Locations [id,name,organizationId,agentCount,activeAgentCount,alertCount,lastScannedOn]
|
||||
└─ Agent (endpoint) GET /Agents (Agent.locationId -> Location.id)
|
||||
Target (SCAN group) GET /Targets [id,name,organizationId,agentCount,activeAgentCount,lastScannedOn] (scannable unit)
|
||||
deviceGroup (global category) GET /deviceGroups [id,name,deviceType] ("Servers"/"Workstations" — spans all orgs)
|
||||
Alert (detection) GET /Alerts (carries organizationId/Name, targetGroupId/Name, severity, mitre*)
|
||||
```
|
||||
|
||||
To list **agents for a client**: resolve org → its Locations (`where organizationId`)
|
||||
→ `GET /Agents?filter={"where":{"locationId":{"inq":[<locIds>]}}}`. (Agents do NOT
|
||||
carry organizationId directly; `deviceGroupId` is a global category, not the client.)
|
||||
|
||||
## Agent object (key fields)
|
||||
|
||||
`id, hostname, name, os, osWindows/osLinux/osOsx/osOther, version, ip/ipPub,
|
||||
active (online), heartbeat, isolated (containment state), dattoAvEnabled,
|
||||
markedForUninstall, markedForUpdate, locationId, deviceGroupId, deviceId, authorized,
|
||||
eppData, rwdInfo`.
|
||||
|
||||
## Alert object (key fields)
|
||||
|
||||
`id, name, description, severity (0 info,1 low,2 medium,3 high,4 critical),
|
||||
mitreTactic, mitreId, hostname, organizationId, organizationName, targetGroupId,
|
||||
targetGroupName, deviceId, agentId, hostId, eventTime, createdOn, sourceType,
|
||||
sourceName, responseData, signed, managed, archived`.
|
||||
|
||||
## Reads (verified)
|
||||
|
||||
| Op | Method/path |
|
||||
|---|---|
|
||||
| Tenant counts | `GET /Organizations/count`, `/Targets/count`, `/Agents/count`, `/Alerts/count` |
|
||||
| Organizations | `GET /Organizations` |
|
||||
| Locations (sites) | `GET /Locations` (filter `where organizationId`) |
|
||||
| Scan target groups | `GET /Targets` |
|
||||
| Agents | `GET /Agents` (filter `where locationId` / `inq`) |
|
||||
| Agent detail | `GET /Agents/{id}` |
|
||||
| Alerts | `GET /Alerts` (filter `where` org/severity/createdOn) |
|
||||
| Alert detail | `GET /Alerts/{id}` |
|
||||
| Extensions | `GET /Extensions` |
|
||||
| Agent reg keys | `GET /agentKeys` (for deploy one-liner) |
|
||||
|
||||
## Mutating (shape-verified from the InfocyteHUNTAPI module; gate behind --confirm)
|
||||
|
||||
| Op | Method/path | Body |
|
||||
|---|---|---|
|
||||
| Scan a target group | `POST /targets/{targetGroupId}/scan` | `{"options":{...ScanOptions}, "where":{...}?}` |
|
||||
| Scan a single target | `POST /targets/scan` | `{"target":"ip/host","targetGroup":{"id":"..."},"options":{...}}` |
|
||||
| Response extension (isolate/kill/quarantine) | `POST /targets/scan` | `{"target":"...","targetGroup":{"id":"..."},"options":{"extensions":[{"id"|"name":...}]}}` |
|
||||
|
||||
**ScanOptions** booleans: `process, module, driver, memory, account, artifact,
|
||||
autostart, application, installed, hook, network, events`, plus
|
||||
`extensions:[{id,args,order}]`.
|
||||
|
||||
These were **not executed** against the prod tenant during build (they scan/isolate
|
||||
real client endpoints). Confirm the response-extension id/name via `GET /Extensions`
|
||||
and run one deliberate test on an ACG-internal machine before relying on them.
|
||||
|
||||
## Deployment (not a REST call)
|
||||
|
||||
The agent installs by running the binary on the endpoint:
|
||||
`agent.exe --key <RegKey> --url https://<instance>.infocyte.com`, or via the official
|
||||
PowerShell wrapper one-liner (`Install-EDR -InstanceName <cname> -RegKey <key>`). Pull
|
||||
a live key from `GET /agentKeys`. Push via GuruRMM `/rmm` script execution or any
|
||||
remote-exec channel. Nothing ties install to Datto RMM.
|
||||
|
||||
## Liftable client
|
||||
|
||||
`github.com/KaseyaDEDR/PowershellTools` — the `InfocyteHUNTAPI` module (Apache-2.0) is
|
||||
a complete REST wrapper and the de-facto API spec. `extension-docs` (Lua 5.3)
|
||||
documents the agent response actions (host isolation `isolator:isolate()`, process
|
||||
kill, quarantine).
|
||||
|
||||
## Webhooks (for the GuruRMM add-on)
|
||||
|
||||
Admin → Webhooks → Add Webhook: EDR POSTs full alert JSON over HTTPS on each detection,
|
||||
with custom auth headers supported. This is the recommended near-real-time feed for a
|
||||
GuruRMM "EDR add-on" dashboard, complemented by REST polling here. (Not wired in this
|
||||
skill yet — Feature 6 work.)
|
||||
@@ -102,11 +102,19 @@ def _t_orgs(rows):
|
||||
|
||||
|
||||
def _t_sites(rows):
|
||||
print(f"{'SITE (TARGET GROUP)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID")
|
||||
print(f"{'SITE (LOCATION)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID")
|
||||
for t in rows:
|
||||
print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} "
|
||||
f"{t.get('activeAgentCount',0):>7} {t.get('alertCount',0):>7} {t.get('id')}")
|
||||
print(f"\n{len(rows)} target groups")
|
||||
print(f"\n{len(rows)} locations")
|
||||
|
||||
|
||||
def _t_scan_targets(rows):
|
||||
print(f"{'SCAN TARGET GROUP':<34} {'AGENTS':>7} {'ACTIVE':>7} {'LAST SCAN':<20} ID")
|
||||
for t in rows:
|
||||
print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} "
|
||||
f"{t.get('activeAgentCount',0):>7} {_trunc(t.get('lastScannedOn'),20):<20} {t.get('id')}")
|
||||
print(f"\n{len(rows)} scan target groups")
|
||||
|
||||
|
||||
def _agent_os(a):
|
||||
@@ -119,13 +127,13 @@ def _agent_os(a):
|
||||
|
||||
def _t_agents(rows):
|
||||
print(f"{'HOSTNAME':<26} {'OS':<8} {'ONLINE':<7} {'ISO':<4} {'AV':<4} "
|
||||
f"{'VERSION':<10} ID")
|
||||
f"{'VERSION':<13} ID")
|
||||
for a in rows:
|
||||
print(f"{_trunc(a.get('hostname') or a.get('name'),26):<26} "
|
||||
f"{_agent_os(a):<8} {('yes' if a.get('active') else 'no'):<7} "
|
||||
f"{('ISO' if a.get('isolated') else '-'):<4} "
|
||||
f"{('on' if a.get('dattoAvEnabled') else '-'):<4} "
|
||||
f"{_trunc(a.get('version'),10):<10} {a.get('id')}")
|
||||
f"{_trunc(a.get('version'),13):<13} {a.get('id')}")
|
||||
print(f"\n{len(rows)} agents")
|
||||
|
||||
|
||||
@@ -154,11 +162,11 @@ def _t_sweep(s):
|
||||
|
||||
# --- org -> target group resolution -------------------------------------------
|
||||
def _agents_for_org(client, org_id, limit):
|
||||
"""Agents across all target groups in an org (Agents carry deviceGroupId)."""
|
||||
out = []
|
||||
for tg in client.list_targets(org_id=org_id):
|
||||
out.extend(client.list_agents(target_group_id=tg.get("id"), limit=limit))
|
||||
return out
|
||||
"""Agents across all sites (Locations) in an org. Agent.locationId -> Location."""
|
||||
loc_ids = [l.get("id") for l in client.list_locations(org_id=org_id) if l.get("id")]
|
||||
if not loc_ids:
|
||||
return []
|
||||
return client.list_agents(location_ids=loc_ids, limit=limit)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -170,12 +178,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sub.add_parser("orgs", help="list organizations (clients)")
|
||||
sub.add_parser("clients", help="alias for orgs")
|
||||
|
||||
sp = sub.add_parser("sites", help="list target groups")
|
||||
sp = sub.add_parser("sites", help="list sites (Locations)")
|
||||
sp.add_argument("--org", help="filter by organizationId")
|
||||
|
||||
sp = sub.add_parser("scan-targets", help="list scan target groups")
|
||||
sp.add_argument("--org", help="filter by organizationId")
|
||||
|
||||
sp = sub.add_parser("agents", help="list agents")
|
||||
sp.add_argument("--org")
|
||||
sp.add_argument("--site", help="target group id")
|
||||
sp.add_argument("--site", help="location id (a single site)")
|
||||
sp.add_argument("--limit", type=int, default=500)
|
||||
|
||||
sp = sub.add_parser("agent", help="agent detail")
|
||||
@@ -200,12 +211,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--regkey", help="registration key (else pulled from /agentKeys)")
|
||||
|
||||
sp = sub.add_parser("scan", help="trigger a scan (gated)")
|
||||
sp.add_argument("--site", required=True, help="target group id")
|
||||
sp.add_argument("--target-group", required=True,
|
||||
help="scan target group id (from scan-targets)")
|
||||
sp.add_argument("--target", help="single ip/host (else whole group)")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("isolate", help="invoke a response extension e.g. host isolation (gated)")
|
||||
sp.add_argument("--site", required=True, help="target group id")
|
||||
sp.add_argument("--target-group", required=True, help="scan target group id")
|
||||
sp.add_argument("--target", required=True, help="ip/host")
|
||||
sp.add_argument("--extension-id")
|
||||
sp.add_argument("--extension-name", default="Host Isolation")
|
||||
@@ -242,12 +254,14 @@ def main(argv=None) -> int:
|
||||
elif cmd in ("orgs", "clients"):
|
||||
_emit(client.list_organizations(), j, _t_orgs)
|
||||
elif cmd == "sites":
|
||||
_emit(client.list_targets(org_id=args.org), j, _t_sites)
|
||||
_emit(client.list_locations(org_id=args.org), j, _t_sites)
|
||||
elif cmd == "scan-targets":
|
||||
_emit(client.list_targets(org_id=args.org), j, _t_scan_targets)
|
||||
elif cmd == "agents":
|
||||
if args.org and not args.site:
|
||||
rows = _agents_for_org(client, args.org, args.limit)
|
||||
else:
|
||||
rows = client.list_agents(target_group_id=args.site, limit=args.limit)
|
||||
rows = client.list_agents(location_id=args.site, limit=args.limit)
|
||||
_emit(rows, j, _t_agents)
|
||||
elif cmd == "agent":
|
||||
_emit(client.get_agent(args.id), j)
|
||||
@@ -270,20 +284,20 @@ def main(argv=None) -> int:
|
||||
_emit(_deploy_payload(client, regkey), j, _t_deploy)
|
||||
elif cmd == "scan":
|
||||
tgt = f"target {args.target}" if args.target else "the whole target group"
|
||||
if not _gate(args, f"scan {tgt} in site {args.site}"):
|
||||
if not _gate(args, f"scan {tgt} in target group {args.target_group}"):
|
||||
return 2
|
||||
if args.target:
|
||||
res = client.scan_single_target(args.site, args.target)
|
||||
res = client.scan_single_target(args.target_group, args.target)
|
||||
else:
|
||||
res = client.scan_target_group(args.site)
|
||||
res = client.scan_target_group(args.target_group)
|
||||
_emit(res, j)
|
||||
elif cmd == "isolate":
|
||||
if not _gate(args, f"run response extension "
|
||||
f"'{args.extension_name or args.extension_id}' "
|
||||
f"on {args.target} (site {args.site})"):
|
||||
f"on {args.target} (target group {args.target_group})"):
|
||||
return 2
|
||||
res = client.run_response_extension(
|
||||
args.site, args.target,
|
||||
args.target_group, args.target,
|
||||
extension_id=args.extension_id,
|
||||
extension_name=None if args.extension_id else args.extension_name)
|
||||
_emit(res, j)
|
||||
|
||||
@@ -228,9 +228,23 @@ class DattoEDRClient:
|
||||
"alertCount": True, "locationCount": True},
|
||||
}) or []
|
||||
|
||||
def list_locations(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]:
|
||||
"""List Locations (= client sites). VERIFIED LIVE. This is the per-client
|
||||
grouping that agents belong to (Agent.locationId -> Location.id), and a
|
||||
Location carries organizationId. Org -> Locations -> Agents is the
|
||||
inventory hierarchy."""
|
||||
filt: dict = {"limit": limit, "order": "name ASC",
|
||||
"fields": {"id": True, "name": True, "organizationId": True,
|
||||
"agentCount": True, "activeAgentCount": True,
|
||||
"alertCount": True, "lastScannedOn": True}}
|
||||
if org_id:
|
||||
filt["where"] = {"organizationId": org_id}
|
||||
return self._get("Locations", filt) or []
|
||||
|
||||
def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]:
|
||||
"""List Targets (= target groups / sites). VERIFIED LIVE.
|
||||
Each Target belongs to an Organization via organizationId."""
|
||||
"""List Targets (= SCAN target groups). VERIFIED LIVE. Distinct from
|
||||
Locations: a Target is the scannable unit (POST targets/{id}/scan). Each
|
||||
Target belongs to an Organization via organizationId."""
|
||||
filt: dict = {"limit": limit, "order": "name ASC",
|
||||
"fields": {"id": True, "name": True, "organizationId": True,
|
||||
"agentCount": True, "activeAgentCount": True,
|
||||
@@ -239,16 +253,17 @@ class DattoEDRClient:
|
||||
filt["where"] = {"organizationId": org_id}
|
||||
return self._get("Targets", filt) or []
|
||||
|
||||
def list_agents(self, org_id: Optional[str] = None,
|
||||
target_group_id: Optional[str] = None,
|
||||
def list_agents(self, location_id: Optional[str] = None,
|
||||
location_ids: Optional[list[str]] = None,
|
||||
limit: int = 500) -> list[dict]:
|
||||
"""List Agents (endpoints). VERIFIED LIVE.
|
||||
Filter by deviceGroupId (target group). Org filtering is done by the CLI
|
||||
via the target-group->org map since Agents carry deviceGroupId, not orgId."""
|
||||
"""List Agents (endpoints). VERIFIED LIVE. Filter by locationId (a single
|
||||
site) or location_ids (an org's sites, via LoopBack `inq`)."""
|
||||
filt: dict = {"limit": limit, "order": "hostname ASC"}
|
||||
where: dict = {}
|
||||
if target_group_id:
|
||||
where["deviceGroupId"] = target_group_id
|
||||
if location_id:
|
||||
where["locationId"] = location_id
|
||||
elif location_ids:
|
||||
where["locationId"] = {"inq": location_ids}
|
||||
if where:
|
||||
filt["where"] = where
|
||||
return self._get("Agents", filt) or []
|
||||
|
||||
51
.claude/skills/datto-edr/scripts/selftest.py
Normal file
51
.claude/skills/datto-edr/scripts/selftest.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-test for the datto-edr skill. Read-only; never mutates the tenant.
|
||||
|
||||
Confirms: token loads from vault, transport works, and the core read endpoints
|
||||
return live data. Exits non-zero on any failure. Set EDR_SUPPRESS_ERRORLOG=1 so
|
||||
expected probe errors don't spam errorlog.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("EDR_SUPPRESS_ERRORLOG", "1")
|
||||
|
||||
from edr_client import DattoEDRClient, DattoEDRError
|
||||
|
||||
|
||||
def main() -> int:
|
||||
checks: list[tuple[str, bool, str]] = []
|
||||
try:
|
||||
client = DattoEDRClient()
|
||||
_ = client.api_token
|
||||
checks.append(("vault token load", True, ""))
|
||||
except DattoEDRError as exc:
|
||||
print(f"[FAIL] token load: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def check(name, fn):
|
||||
try:
|
||||
val = fn()
|
||||
checks.append((name, True, str(val)))
|
||||
except Exception as exc: # noqa: BLE001 - selftest summarizes failures
|
||||
checks.append((name, False, str(exc)))
|
||||
|
||||
check("status rollup", lambda: client.get_status()["agents"])
|
||||
check("list organizations", lambda: f"{len(client.list_organizations())} orgs")
|
||||
check("list locations", lambda: f"{len(client.list_locations())} sites")
|
||||
check("list targets", lambda: f"{len(client.list_targets())} scan groups")
|
||||
check("list agents (limit 5)", lambda: f"{len(client.list_agents(limit=5))} agents")
|
||||
check("list alerts (limit 5)", lambda: f"{len(client.list_alerts(limit=5))} alerts")
|
||||
|
||||
ok = True
|
||||
for name, passed, detail in checks:
|
||||
marker = "[OK]" if passed else "[FAIL]"
|
||||
print(f"{marker} {name}{(' -> ' + detail) if detail else ''}")
|
||||
ok = ok and passed
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Submodule projects/msp-tools/guru-rmm updated: de30ebcb9b...3b3f06976c
Reference in New Issue
Block a user