diff --git a/.claude/skills/datto-edr/SKILL.md b/.claude/skills/datto-edr/SKILL.md index 3291135a..b8a210be 100644 --- a/.claude/skills/datto-edr/SKILL.md +++ b/.claude/skills/datto-edr/SKILL.md @@ -82,61 +82,82 @@ $EDR detections [--org ] [--site ] [--severity 0-4] [--days N $EDR detection # full alert detail $EDR sweep [--org ] # per-client posture rollup (headline view) $EDR extensions # list response/collection extensions +$EDR tasks [--type "Scan - EDR"] [--limit N] # recent userTasks (scan/analysis jobs) +$EDR task # one userTask (scan job) detail/status $EDR deploy-cmd [--regkey ] # 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`. +`--json` is a global flag (place before the subcommand): `$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 --confirm -$EDR scan --target-group --target --confirm +Scan/isolate go through `POST Agents/scan` with a **`where` filter selecting the +agents**. The CLI REQUIRES explicit `--agent` id(s): an absent `where` scans the +ENTIRE tenant (the "156-host" footgun), so the client refuses an empty list. -# Response extension (host isolation, kill, quarantine) — runs as a scan w/ extension -$EDR isolate --target-group --target --extension-name "Host Isolation" --confirm +```bash +# EDR-scan specific agent(s). Repeat --agent for several. POST Agents/scan +# {"where":{"and":[{"id":[ids]}]}, "options":{}, "taskName":"Scan - EDR"} +$EDR scan --agent [--agent ...] --confirm + +# Response extension (Host Isolation, etc.) on agent(s) — rides Agents/scan w/ options.extensions +$EDR isolate --agent [--extension-name "Host Isolation [Win/Linux]"] --confirm +$EDR isolate --agent --extension-id --confirm + +# Cancel a running scan/task; create a group; mint a group registration key +$EDR cancel --confirm +$EDR create-group --name "[TEST] Foo" --org --confirm +$EDR mint-key --group --key <10char> --confirm # Power tool — any endpoint. Non-GET requires --confirm. $EDR raw GET Agents --filter '{"limit":1}' -$EDR raw POST targets//scan --data '{"options":{...}}' --confirm +$EDR raw POST Agents/scan --data '{"where":{"and":[{"id":[""]}]},"options":{},"taskName":"Scan - EDR"}' --confirm ``` +**The single-agent scan body that works:** `{"where":{"and":[{"id":[""]}]}, +"options":{}, "taskName":"Scan - EDR"}`. A bare `{"id":{"inq":[...]}}` returns HTTP +412 "column reference id is ambiguous" — the `and`-wrap is required. AV scans +(`Scan - AV Quick/Full`) are policy-driven, NOT callable via this endpoint. + ## 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`): +`/agentKeys`). To land an agent in a SPECIFIC group: `create-group` → `mint-key` +(the key `id` is caller-supplied) → install with that `-RegKey`. ``` -Install-EDR -InstanceName azcomp4587 -RegKey +Install-EDR -URL "https://azcomp4587.infocyte.com" -RegKey ``` -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. +**Pass the full `-URL`, not `-InstanceName azcomp4587`** — the install script's loose +`.com` regex matches "zcom" inside the cname, leaving `--url` empty and the agent +erroring `a value is required for '--url'`. Push via **GuruRMM** (`/rmm`) or any +remote-exec channel. `RegKey` auto-approves + adds to the key's target group; without +it the agent registers but awaits manual approval. ## 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. +- `scan`, `isolate`, `cancel`, `create-group`, `mint-key`, and any non-GET `raw` print + a `[DRY-RUN]` line and exit non-zero unless `--confirm` is passed. +- `scan`/`isolate` additionally REFUSE an empty `--agent` list (would scan the whole + tenant). **Never** scan/isolate casually — these hit live client production endpoints. + Confirm the agent id with the read commands first. ## 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. +**Live-verified 2026-06-25 (exercised end-to-end on the tenant):** all reads +(`status`, `orgs`, `sites`, `scan-targets`, `agents` incl. per-org Location mapping, +`agent`, `detections`, `detection`, `sweep`, `extensions`, `tasks`, `task`); the full +deploy lifecycle (`create-group` → `mint-key` → install via `/rmm` → agent registered +into the group); `scan` (targeted single-agent — confirmed "Scanning 1 host"); `cancel`; +`deploy-cmd`; `raw`; and the `--confirm` + empty-`--agent` guards. -**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. +**RUN-unverified (shape correct, not fired at prod):** `isolate` — it rides the same +`Agents/scan` call with the Host Isolation extension in `options.extensions`; confirm +the extension id via `$EDR extensions` and test on an ACG-internal box before relying +on it (it cuts the endpoint off the network). AV scans are policy-driven (not callable). ## Relationship to GuruRMM (Feature 6) diff --git a/.claude/skills/datto-edr/references/api-reference.md b/.claude/skills/datto-edr/references/api-reference.md index b182e5b6..e46f71a2 100644 --- a/.claude/skills/datto-edr/references/api-reference.md +++ b/.claude/skills/datto-edr/references/api-reference.md @@ -70,30 +70,59 @@ sourceName, responseData, signed, managed, archived`. | 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) | +| Agent reg keys | `GET /agentKeys` (each is `{id:, targetId}`) | +| Tasks (scan jobs) | `GET /userTasks` (filter `where type`, e.g. `"Scan - EDR"`) | +| Task detail | `GET /userTasks/{id}` | -## Mutating (shape-verified from the InfocyteHUNTAPI module; gate behind --confirm) +## Mutating (VERIFIED LIVE 2026-06-25; gate behind --confirm) + +The Infocyte-module scan routes (`targets/{id}/scan`, `targets/scan`, `scans`) are +**DEAD (404)**. The live mechanism (read from the console JS bundle + run live) is a +single endpoint selecting agents by a LoopBack `where`: | 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":...}]}}` | +| **Scan agent(s)** | `POST /Agents/scan` | `{"where":{"and":[{"id":["",...]}]}, "options":{}, "taskName":"Scan - EDR"}` | +| **Response ext** (isolate/kill) | `POST /Agents/scan` | `{"where":{"and":[{"id":[...]}]}, "options":{"extensions":[{"id":""}]}, "taskName":"Response"}` | +| **Cancel a scan/task** | `POST /userTasks/{id}/cancel` | — (204) | +| **Create group** | `POST /Targets` | `{"name":"...","organizationId":"..."}` | +| **Mint reg key** | `POST /agentKeys` | `{"id":"<10char,caller-supplied>","targetId":""}` | -**ScanOptions** booleans: `process, module, driver, memory, account, artifact, -autostart, application, installed, hook, network, events`, plus -`extensions:[{id,args,order}]`. +**CRITICAL footguns:** +- The `where` is **REQUIRED**. An absent/empty `where` scans the **ENTIRE tenant** + ("Scanning 156 hosts"). The CLI refuses an empty agent list. +- Use the **AND-wrapped** form `{"where":{"and":[{"id":[...]}]}}`. A bare + `{"where":{"id":{"inq":[...]}}}` returns **HTTP 412 "column reference id is + ambiguous"** (the scan query joins tables; the `and`-wrap disambiguates). +- Targeting is by **Agent.id** (not `deviceId`, which is null right after enroll). +- `scanType` is a client-side UI enum, never sent. AV scans (`Scan - AV Quick/Full`) + are **policy-driven, not callable** via this endpoint. +- Sibling endpoints exist for broader scope: `POST /organizations/scan`, + `/locations/scan`, `/locations/{id}/scan` (same `{where, options}` shape). -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. +**options** (EDR forensic toggles, all optional; empty `{}` is valid): +`process, module, driver, memory, account, artifact, autostart, application, +installed, hook, network, events`, plus `extensions:[{id,args,order}]`. + +`isolate` is shape-correct but RUN-unverified (cuts the endpoint off-network) — confirm +the extension id via `GET /Extensions` (e.g. `Host Isolation [Win/Linux]`) and test on +an ACG-internal box first. ## Deployment (not a REST call) The agent installs by running the binary on the endpoint: `agent.exe --key --url https://.infocyte.com`, or via the official -PowerShell wrapper one-liner (`Install-EDR -InstanceName -RegKey `). Pull -a live key from `GET /agentKeys`. Push via GuruRMM `/rmm` script execution or any +PowerShell wrapper one-liner. **Pass the FULL `-URL`, not `-InstanceName `** — +the install script's loose `.com` regex matches "zcom" inside `azcomp4587`, leaving +`--url` empty (`a value is required for '--url'`): + +``` +Install-EDR -URL "https://azcomp4587.infocyte.com" -RegKey +``` + +To land an agent in a SPECIFIC group: `POST /Targets` (create group) → `POST /agentKeys` +(mint key, caller-supplied `id`) → install with that key. Pull existing keys from +`GET /agentKeys` (each `{id:, targetId}`). Push via GuruRMM `/rmm` or any remote-exec channel. Nothing ties install to Datto RMM. ## Liftable client diff --git a/.claude/skills/datto-edr/scripts/edr.py b/.claude/skills/datto-edr/scripts/edr.py index 2accfcb7..7b645e36 100644 --- a/.claude/skills/datto-edr/scripts/edr.py +++ b/.claude/skills/datto-edr/scripts/edr.py @@ -160,6 +160,14 @@ def _t_sweep(s): print(f"\n{len(rows)} clients (sorted by 7-day detection volume)") +def _t_tasks(rows): + print(f"{'WHEN':<20} {'TYPE':<16} {'STATUS':<11} {'P%':>4} ID") + for t in rows: + print(f"{_trunc(t.get('createdOn'),20):<20} {_trunc(t.get('type'),16):<16} " + f"{_trunc(t.get('status'),11):<11} {str(t.get('progress','')):>4} {t.get('id')}") + print(f"\n{len(rows)} tasks") + + # --- org -> target group resolution ------------------------------------------- def _agents_for_org(client, org_id, limit): """Agents across all sites (Locations) in an org. Agent.locationId -> Location.""" @@ -210,22 +218,43 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("deploy-cmd", help="emit the agent install one-liner") sp.add_argument("--regkey", help="registration key (else pulled from /agentKeys)") - sp = sub.add_parser("scan", help="trigger a scan (gated)") - 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 = sub.add_parser("scan", help="trigger an EDR scan on specific agent(s) (gated)") + sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID", + help="agent id to scan (repeatable). REQUIRED - omitting scans the whole tenant.") + sp.add_argument("--task-name", default="Scan - EDR") 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("--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") + sp = sub.add_parser("isolate", help="run a response extension (host isolation) on agent(s) (gated)") + sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID", + help="agent id (repeatable)") + sp.add_argument("--extension-id", help="extension id (else resolved from --extension-name)") + sp.add_argument("--extension-name", default="Host Isolation [Win/Linux]") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("tasks", help="list recent userTasks (scan/analysis jobs)") + sp.add_argument("--type", dest="task_type", help="filter by type e.g. 'Scan - EDR'") + sp.add_argument("--limit", type=int, default=20) + + sp = sub.add_parser("task", help="userTask (scan job) detail") + sp.add_argument("id") + + sp = sub.add_parser("cancel", help="cancel a running userTask e.g. a scan (gated)") + sp.add_argument("id") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("create-group", help="create an EDR target group (gated)") + sp.add_argument("--name", required=True) + sp.add_argument("--org", required=True, help="organizationId") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("mint-key", help="mint a registration key for a group (gated)") + sp.add_argument("--group", required=True, help="target group id") + sp.add_argument("--key", required=True, help="10-char key string (caller-supplied)") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("raw", help="call any endpoint") sp.add_argument("method", help="GET/POST/PUT/DELETE") - sp.add_argument("path", help="e.g. Agents or targets/{id}/scan") + sp.add_argument("path", help="e.g. Agents or Agents/scan") sp.add_argument("--filter", help="LoopBack filter JSON (GET)") sp.add_argument("--data", help="request body JSON") sp.add_argument("--confirm", action="store_true", @@ -283,24 +312,52 @@ def main(argv=None) -> int: regkey = keys[0].get("key") or keys[0].get("id") _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 target group {args.target_group}"): + ids = args.agents or [] + if not ids: + print("[BLOCKED] scan requires at least one --agent . Omitting " + "agents would scan the ENTIRE tenant (156-host footgun).", + file=sys.stderr) return 2 - if args.target: - res = client.scan_single_target(args.target_group, args.target) - else: - res = client.scan_target_group(args.target_group) - _emit(res, j) + if not _gate(args, f"EDR-scan {len(ids)} agent(s): {', '.join(ids)}"): + return 2 + _emit(client.scan_agents(ids, task_name=args.task_name), j) elif cmd == "isolate": - if not _gate(args, f"run response extension " - f"'{args.extension_name or args.extension_id}' " - f"on {args.target} (target group {args.target_group})"): + ids = args.agents or [] + if not ids: + print("[BLOCKED] isolate requires at least one --agent .", + file=sys.stderr) return 2 - res = client.run_response_extension( - args.target_group, args.target, - extension_id=args.extension_id, - extension_name=None if args.extension_id else args.extension_name) - _emit(res, j) + ext_id = args.extension_id + if not ext_id: # resolve name -> id + exts = client.list_extensions() + match = [e for e in (exts or []) + if (e.get("name") or "").lower() == args.extension_name.lower()] + if not match: + print(f"[ERROR] extension '{args.extension_name}' not found. " + f"Run `extensions` to list, or pass --extension-id.", + file=sys.stderr) + return 1 + ext_id = match[0].get("id") + if not _gate(args, f"run extension {ext_id} ('{args.extension_name}') " + f"on {len(ids)} agent(s): {', '.join(ids)}"): + return 2 + _emit(client.run_extension_on_agents(ids, ext_id), j) + elif cmd == "tasks": + _emit(client.list_tasks(type_=args.task_type, limit=args.limit), j, _t_tasks) + elif cmd == "task": + _emit(client.get_task(args.id), j) + elif cmd == "cancel": + if not _gate(args, f"cancel userTask {args.id}"): + return 2 + _emit(client.cancel_task(args.id), j) + elif cmd == "create-group": + if not _gate(args, f"create group '{args.name}' in org {args.org}"): + return 2 + _emit(client.create_group(args.name, args.org), j) + elif cmd == "mint-key": + if not _gate(args, f"mint key '{args.key}' for group {args.group}"): + return 2 + _emit(client.mint_registration_key(args.group, args.key), j) elif cmd == "raw": method = args.method.upper() if method != "GET" and not args.confirm: @@ -322,23 +379,27 @@ def main(argv=None) -> int: def _deploy_payload(client, regkey): - instance = client.api_base_url.replace("https://", "").replace("/api", "") + instance = client.api_base_url.replace("https://", "").replace("/api", "").rstrip("/") cname = instance.split(".")[0] + full_url = f"https://{instance}" keypart = f" -RegKey {regkey}" if regkey else "" + # Pass the FULL -URL, never -InstanceName : the install script's loose + # `.com` regex matches "zcom" inside azcomp4587 and leaves --url empty. oneliner = ( "[System.Net.ServicePointManager]::SecurityProtocol = " "[Enum]::ToObject([System.Net.SecurityProtocolType], 3072); " "(new-object Net.WebClient).DownloadString(" "\"https://raw.githubusercontent.com/Infocyte/PowershellTools/master/" "AgentDeployment/install_huntagent.ps1\") | iex; " - f"Install-EDR -InstanceName {cname}{keypart}" + f"Install-EDR -URL \"{full_url}\"{keypart}" ) return { "instance": cname, + "url": full_url, "regkey": regkey or "(none found - agent will register but await approval)", "powershell_oneliner": oneliner, "note": "Run on the target via GuruRMM script-execution or any remote-exec " - "channel. RegKey auto-approves + adds to the default target group.", + "channel. RegKey auto-approves + adds to the key's target group.", } diff --git a/.claude/skills/datto-edr/scripts/edr_client.py b/.claude/skills/datto-edr/scripts/edr_client.py index f127278f..c0dcda17 100644 --- a/.claude/skills/datto-edr/scripts/edr_client.py +++ b/.claude/skills/datto-edr/scripts/edr_client.py @@ -242,9 +242,10 @@ class DattoEDRClient: return self._get("Locations", filt) or [] def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]: - """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.""" + """List Targets (= scan groups). VERIFIED LIVE. A grouping concept that + belongs to an Organization via organizationId (often aliases a Location + id). NOTE: scanning is per-AGENT via Agents/scan - the legacy + targets/{id}/scan route is DEAD (404) on this tenant.""" filt: dict = {"limit": limit, "order": "name ASC", "fields": {"id": True, "name": True, "organizationId": True, "agentCount": True, "activeAgentCount": True, @@ -324,64 +325,98 @@ class DattoEDRClient: # ====================================================================== # MUTATING METHODS (caller MUST gate behind --confirm) # ---------------------------------------------------------------------- - # Shapes derived from the KaseyaDEDR InfocyteHUNTAPI module (scan.ps1): - # scan a target group : POST targets/{targetGroupId}/scan {options, where} - # scan a single target: POST targets/scan {target, targetGroup, options} - # response/isolate : POST targets/scan with a response extension - # NOT executed against the live prod tenant during build (would scan/isolate - # real client machines). Treat as SHAPE-VERIFIED, RUN-UNVERIFIED until first - # deliberate use. + # The Infocyte-module scan routes (targets/{id}/scan, targets/scan, scans) + # are DEAD (404) on Datto EDR. The live mechanism, read verbatim from the + # console JS bundle, is: + # POST agents/scan {where:, options:, taskName} + # where `where` SELECTS the agents. An ABSENT/empty `where` scans the ENTIRE + # tenant (the 156-host footgun) - so scan_agents REFUSES an empty id list. + # Response extensions (isolate/kill/quarantine) ride the same call via + # options.extensions. (Verified live 2026-06-25.) # ====================================================================== - DEFAULT_SCAN_OPTIONS = { - "process": True, "module": True, "driver": True, "memory": True, - "account": True, "artifact": True, "autostart": True, "application": True, - "installed": True, "hook": False, "network": False, "events": True, - } + def scan_agents(self, agent_ids: list[str], + options: Optional[dict] = None, + task_name: str = "Scan - EDR") -> Any: + """Trigger an EDR scan on specific agents (POST agents/scan). - def scan_target_group(self, target_group_id: str, - options: Optional[dict] = None, - where: Optional[dict] = None) -> Any: - """Trigger a scan across a target group (POST targets/{id}/scan). + Targets by Agent.id via a LoopBack where filter: + {"where":{"id":{"inq":[ids]}}, "options":{}, "taskName":"Scan - EDR"}. + REFUSES an empty agent_ids list - an empty where scans the whole tenant. + `options` may carry EDR forensic toggles + extensions; empty {} is valid. STATE-CHANGING - gate behind --confirm at the call site.""" - body: dict = {"options": options or self.DEFAULT_SCAN_OPTIONS} - if where is not None: - body["where"] = where - return self._request("POST", f"targets/{target_group_id}/scan", body=body) - - def scan_single_target(self, target_group_id: str, target: str, - options: Optional[dict] = None) -> Any: - """Scan a single on-demand target (POST targets/scan). - STATE-CHANGING - gate behind --confirm at the call site.""" - body: dict = { - "target": target, - "targetGroup": {"id": target_group_id}, - "options": options or self.DEFAULT_SCAN_OPTIONS, + ids = [i for i in (agent_ids or []) if i] + if not ids: + raise DattoEDRError( + "scan_agents refused: empty agent list. An absent `where` scans " + "the ENTIRE tenant (156-host footgun) - pass explicit agent id(s)." + ) + # AND-wrapped form is REQUIRED: a bare {id:{inq:[...]}} returns HTTP 412 + # "column reference id is ambiguous" (the backend joins tables). The + # console builds {and:[{id:[...]}]}; verified live -> "Scanning 1 host". + body = { + "where": {"and": [{"id": ids}]}, + "options": options or {}, + "taskName": task_name, } - return self._request("POST", "targets/scan", body=body) + return self._request("POST", "Agents/scan", body=body) def list_extensions(self, limit: int = 200) -> Any: """List agent extensions (response + collection). Read. - Response extensions (e.g. host isolation) are invoked via a scan body.""" + Response extensions (e.g. Host Isolation) are invoked via scan options.""" return self._get("Extensions", {"limit": limit}) or [] - def run_response_extension(self, target_group_id: str, target: str, - extension_id: Optional[str] = None, - extension_name: Optional[str] = None) -> Any: - """Invoke a response extension on a target (isolate/kill/quarantine). - Per the module, a response runs as a scan with an extension in options. - UNVERIFIED shape - confirm the extension id/name from list_extensions(). + def run_extension_on_agents(self, agent_ids: list[str], extension_id: str, + task_name: str = "Response") -> Any: + """Invoke a response extension (isolate/kill/quarantine) on specific + agents - rides POST agents/scan with the extension in options.extensions. + Same empty-list guard as scan_agents. Confirm the extension id via + list_extensions() (e.g. 'Host Isolation [Win/Linux]'). STATE-CHANGING - gate behind --confirm at the call site.""" - ext: dict = {} - if extension_id: - ext["id"] = extension_id - if extension_name: - ext["name"] = extension_name + ids = [i for i in (agent_ids or []) if i] + if not ids: + raise DattoEDRError( + "run_extension_on_agents refused: empty agent list (would target " + "the whole tenant). Pass explicit agent id(s)." + ) + if not extension_id: + raise DattoEDRError("run_extension_on_agents requires an extension_id.") body = { - "target": target, - "targetGroup": {"id": target_group_id}, - "options": {"extensions": [ext]}, + "where": {"and": [{"id": ids}]}, # AND-wrapped (see scan_agents) + "options": {"extensions": [{"id": extension_id}]}, + "taskName": task_name, } - return self._request("POST", "targets/scan", body=body) + return self._request("POST", "Agents/scan", body=body) + + # -- task status / control -------------------------------------------------- + def list_tasks(self, type_: Optional[str] = None, limit: int = 20) -> list[dict]: + """List userTasks (scan/analysis jobs), newest first. Read.""" + filt: dict = {"limit": limit, "order": "createdOn DESC"} + if type_: + filt["where"] = {"type": type_} + return self._get("userTasks", filt) or [] + + def get_task(self, task_id: str) -> dict: + """Get one userTask (scan job) detail. Read.""" + return self._get(f"userTasks/{task_id}") or {} + + def cancel_task(self, task_id: str) -> Any: + """Cancel a running userTask, e.g. a scan (POST userTasks/{id}/cancel -> + 204). STATE-CHANGING - gate behind --confirm at the call site.""" + return self._request("POST", f"userTasks/{task_id}/cancel") + + # -- provisioning ----------------------------------------------------------- + def create_group(self, name: str, organization_id: str) -> Any: + """Create an EDR target group under an org (POST Targets). + STATE-CHANGING - gate behind --confirm at the call site.""" + return self._request("POST", "Targets", + body={"name": name, "organizationId": organization_id}) + + def mint_registration_key(self, target_id: str, key: str) -> Any: + """Mint an agent registration key tied to a target group (POST agentKeys). + The key string `id` is CALLER-SUPPLIED (10-char); an absent id 500s. + STATE-CHANGING - gate behind --confirm at the call site.""" + return self._request("POST", "agentKeys", + body={"id": key, "targetId": target_id}) # ====================================================================== # POWER TOOL