fix(bitdefender): errorlog rule-compliance + moveCustomGroup param + ASCII-clean code

Finalizing the skill to "done, no errors, all skill rules":
- errorlog compliance: gz.py no longer logs EXPECTED API responses (validation,
  method-not-found, not-configured, rate-limit, expected state) or `raw`/selftest
  runs to errorlog.md. Per CLAUDE.md "do not log expected/handled conditions".
  Verified: selftest + probes leave errorlog unchanged.
- moveCustomGroup: param is `parentId`, not `newParentId` (6th doc-vs-live fix
  caught by a full param-shape audit).
- ASCII-clean code: removed all non-ASCII (em-dashes, U+21D2 arrow) from scripts
  (avoids cp1252 encode errors; aligns with the ASCII-markers rule).
- api-reference updated.

Verified: 18/18 read commands rc=0 live; selftest 75/75; parser builds; ASCII
markers + vault load + errorlog helper present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 17:06:46 -07:00
parent f55feb07fa
commit 8f17c17258
4 changed files with 90 additions and 46 deletions

View File

@@ -109,7 +109,7 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node.
| `createCustomGroup` | `name, parentId?` | VERIFIED | Create a custom group; returns new group id. |
| `deleteEndpoint` | `endpointId` | VERIFIED (destructive) | Remove an endpoint from inventory. CLI-gated behind `--confirm`. |
| `deleteCustomGroup` | `groupId` | VERIFIED (destructive) | Delete a custom group. CLI-gated behind `--confirm`. |
| `moveCustomGroup` | `groupId, newParentId` | VERIFIED | Re-parent a custom group. |
| `moveCustomGroup` | `groupId, parentId` (NOT newParentId — verified live) | VERIFIED | Re-parent a custom group. |
| `assignPolicy` | `policyId, targetIds[], forcePolicyInheritance?, inheritFromAbove?` | VERIFIED (docs+probe) | CLI `assign-policy`, gated. See policies section. |
| `createReconfigureClientTask` | `targetIds[] (req) + reconfigure body` | VERIFIED (probe) | CLI `reconfigure`, gated. STATE-CHANGING. |
| `setEndpointLabel` | `endpointId (req), label (req)` | VERIFIED (probe) | CLI `set-label`, gated. STATE-CHANGING. |

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""CLI for the bitdefender skill GravityZone Cloud Public API.
"""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
@@ -62,6 +62,45 @@ def _log_skill_error(skill, msg, context=""):
pass
# Substrings that mark an error as an EXPECTED API response (validation, probing,
# not-configured, transient rate-limit, expected state) rather than a genuine skill
# failure. The errorlog rule (CLAUDE.md) forbids logging expected/handled
# conditions - only real failures worth pattern-spotting. These are NOT logged.
_EXPECTED_ERROR_MARKERS = (
"required parameter is missing",
"invalid value",
"not expected",
"method not found",
"is not available",
"not available yet",
"were not set",
"not configured",
"should not be used with",
"you must specify a value",
"cannot be restored from isolation",
"429",
"too many requests",
)
def _is_expected_error(msg: str) -> bool:
m = (msg or "").lower()
return any(marker in m for marker in _EXPECTED_ERROR_MARKERS)
def _should_log_error(command: str, msg: str) -> bool:
"""Decide whether a GravityZoneError is a genuine skill failure worth logging.
Skips: explicit suppression (selftest/probes set GZ_SUPPRESS_ERRORLOG), the
exploratory `raw` subcommand, and expected API validation/probe responses.
"""
if os.environ.get("GZ_SUPPRESS_ERRORLOG"):
return False
if command == "raw":
return False
return not _is_expected_error(msg)
def _emit(obj, as_json: bool, table_fn=None) -> None:
if as_json or table_fn is None:
print(json.dumps(obj, indent=2, default=_json_default))
@@ -676,7 +715,7 @@ def cmd_make_group(client, args):
# 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.
# (EDR) module - gate them in `raw` as well as via the dedicated subcommands.
DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
"createreconfigure", "isolat", "addtoblocklist",
"removefromblocklist", "assignpolicy",
@@ -1019,7 +1058,7 @@ def build_parser() -> argparse.ArgumentParser:
help="Remove one blocklist entry (gated).",
parents=[common])
sp.add_argument("--id", required=True,
help="hashItemId the 'id' from `blocklist` output.")
help="hashItemId - the 'id' from `blocklist` output.")
sp.add_argument("--confirm", action="store_true")
sp = sub.add_parser("assign-policy",
@@ -1208,8 +1247,9 @@ def main(argv=None) -> int:
return rc if isinstance(rc, int) else 0
except GravityZoneError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
_log_skill_error("bitdefender", f"{exc}",
context=f"cmd={getattr(args, 'command', '?')}")
cmd = getattr(args, "command", "?")
if _should_log_error(cmd, str(exc)):
_log_skill_error("bitdefender", f"{exc}", context=f"cmd={cmd}")
return 1
except KeyboardInterrupt:
return 130

View File

@@ -41,7 +41,7 @@ except ImportError: # pragma: no cover - depends on environment
# Cap upstream error bodies surfaced in exceptions. The GravityZone key never
# appears here, but `raw` can call arbitrary methods whose responses may reflect
# other data bound the blast radius rather than echo full bodies into logs.
# other data - bound the blast radius rather than echo full bodies into logs.
ERROR_BODY_MAX_CHARS = 500
# --- constants ----------------------------------------------------------------
@@ -115,7 +115,7 @@ def load_api_key() -> str:
"""Load the GravityZone API key.
Order: GRAVITYZONE_API_KEY env override, then the SOPS vault wrapper.
Never returns an empty key raises if it cannot resolve one.
Never returns an empty key - raises if it cannot resolve one.
"""
env_key = os.environ.get("GRAVITYZONE_API_KEY")
if env_key:
@@ -255,7 +255,7 @@ class GravityZoneClient:
return self._jsonrpc_request("companies", "getCompanyDetails", {}) or {}
def get_company_details(self, company_id: Optional[str] = None) -> dict:
"""Company detail (companies.getCompanyDetails). No id own company."""
"""Company detail (companies.getCompanyDetails). No id -> own company."""
params: dict = {}
if company_id:
params["companyId"] = company_id
@@ -491,7 +491,7 @@ class GravityZoneClient:
def delete_package(self, package_id: str) -> Any:
"""Delete an installation package (packages.deletePackage).
VERIFIED LIVE 2026-06-21: the param is `packageId` (NOT packageName/
companyId those error "not expected"). STATE-CHANGING gate at call site."""
companyId - those error "not expected"). STATE-CHANGING - gate at call site."""
return self._jsonrpc_request(
"packages", "deletePackage", {"packageId": package_id}
)
@@ -542,10 +542,11 @@ class GravityZoneClient:
)
def move_custom_group(self, group_id: str, new_parent_id: str) -> Any:
# API param is `parentId` (verified live 2026-06-21), NOT `newParentId`.
return self._jsonrpc_request(
"network",
"moveCustomGroup",
{"groupId": group_id, "newParentId": new_parent_id},
{"groupId": group_id, "parentId": new_parent_id},
)
# ======================================================================
@@ -610,10 +611,10 @@ class GravityZoneClient:
"""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.
task ids. 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.
# (NOT an `endpointIds` array - that errors "not expected"). Loop for many.
results = []
for eid in endpoint_ids:
results.append(self._jsonrpc_request(
@@ -625,10 +626,10 @@ class GravityZoneClient:
"""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.
task ids. 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.
# Note: fails if the isolation task is still in progress - wait + retry.
results = []
for eid in endpoint_ids:
results.append(self._jsonrpc_request(
@@ -651,7 +652,7 @@ class GravityZoneClient:
`hash_type` is an int (1 is the common value seen live; see the
GravityZone console / API docs for the full mapping). `hash_list` is an
array of hash strings. `source_info` is a free-text description.
STATE-CHANGING gate behind --confirm at the call site.
STATE-CHANGING - gate behind --confirm at the call site.
"""
params: dict = {
"companyId": company_id,
@@ -666,7 +667,7 @@ class GravityZoneClient:
def remove_from_blocklist(self, hash_item_id: str) -> Any:
"""Remove one blocklist entry (incidents.removeFromBlocklist).
STATE-CHANGING gate behind --confirm at the call site.
STATE-CHANGING - gate behind --confirm at the call site.
UNVERIFIED: the param name `hashItemId` is the candidate (the `id`
field from getBlocklistItems). Confirm against the official Bitdefender
@@ -680,9 +681,9 @@ class GravityZoneClient:
# POLICY ASSIGNMENT (state-changing; gate behind --confirm at call site)
# ----------------------------------------------------------------------
# NOTE: getPolicyDetails (above) returns the FULL granular module config
# (verified live 2026-06-21 the earlier "shallow only" claim was wrong).
# The Public API still has NO create/edit/clone policy method authoring
# stays in the console but assigning an EXISTING policy is supported here.
# (verified live 2026-06-21 - the earlier "shallow only" claim was wrong).
# The Public API still has NO create/edit/clone policy method - authoring
# stays in the console - but assigning an EXISTING policy is supported here.
# ======================================================================
def assign_policy(
self,
@@ -699,7 +700,7 @@ class GravityZoneClient:
error. `forcePolicyInheritance` optionally pushes the policy down to
sub-items. (To make a target INHERIT instead, call with
{targetIds, inheritFromAbove:true} and no policyId via `raw`.)
STATE-CHANGING gate at the call site behind --confirm.
STATE-CHANGING - gate at the call site behind --confirm.
Docs: bitdefender.com/business/support/en/77212-924802-assignpolicy.html
"""
params: dict = {
@@ -754,7 +755,7 @@ class GravityZoneClient:
)
# ======================================================================
# REPORTS (module `/reports`) VERIFIED LIVE
# REPORTS (module `/reports`) - VERIFIED LIVE
# ======================================================================
def list_reports(self, page: int = 1, per_page: int = 100) -> dict:
"""List saved reports (reports.getReportsList)."""
@@ -765,7 +766,7 @@ class GravityZoneClient:
def get_report_links(self, report_id: str) -> Any:
"""Get download links for a generated report (reports.getDownloadLinks).
Param name `reportId` is the candidate confirm against the console if
Param name `reportId` is the candidate - confirm against the console if
it errors.
"""
return self._jsonrpc_request(
@@ -773,7 +774,7 @@ class GravityZoneClient:
)
# ======================================================================
# ACCOUNTS (module `/accounts`) VERIFIED LIVE (read)
# ACCOUNTS (module `/accounts`) - VERIFIED LIVE (read)
# ======================================================================
def list_accounts(self, page: int = 1, per_page: int = 100) -> dict:
"""List GravityZone console accounts/users (accounts.getAccountsList)."""
@@ -852,7 +853,7 @@ class GravityZoneClient:
def configure_notifications_settings(self, settings: dict) -> Any:
"""Set notification settings (accounts.configureNotificationsSettings).
STATE-CHANGING there are NO required params, so an empty payload is
STATE-CHANGING - there are NO required params, so an empty payload is
accepted; the caller must pass the intended settings object. Gate at the
call site behind --confirm."""
return self._jsonrpc_request(
@@ -862,10 +863,10 @@ class GravityZoneClient:
# ======================================================================
# PUSH EVENT SERVICE (module `/push`)
# ----------------------------------------------------------------------
# `get`/`stats` are read (but error when the service was never configured
# `get`/`stats` are read (but error when the service was never configured -
# that is an EXPECTED state, not a failure; the CLI handles it cleanly).
# `set` is STATE-CHANGING (it configures where GravityZone POSTs security
# events) gate behind --confirm at the call site.
# events) - gate behind --confirm at the call site.
# ======================================================================
def get_push_settings(self) -> dict:
"""Current push event service settings (push.getPushEventSettings)."""
@@ -890,9 +891,9 @@ class GravityZoneClient:
2026-06-21). When enabling, `serviceSettings.url` is the receiver
endpoint GravityZone POSTs events to. The nested shape
(serviceType/serviceSettings/subscribeToEventTypes) follows Bitdefender's
documented push API and is UNVERIFIED beyond `status` on this tenant
documented push API and is UNVERIFIED beyond `status` on this tenant -
confirm the first successful enable against the live response.
STATE-CHANGING gate at the call site behind --confirm.
STATE-CHANGING - gate at the call site behind --confirm.
"""
params: dict = {"status": status, "serviceType": service_type}
service_settings: dict = {"requireValidSslCertificate": require_valid_ssl}
@@ -907,7 +908,7 @@ class GravityZoneClient:
def send_test_push_event(self, event_type: str, extra: Optional[dict] = None) -> Any:
"""Send a test push event (push.sendTestPushEvent). Requires `eventType`
(verified). Fires against the configured receiver STATE-ADJACENT, gate
(verified). Fires against the configured receiver - STATE-ADJACENT, gate
at the call site behind --confirm."""
params: dict = {"eventType": event_type}
if extra:
@@ -915,7 +916,7 @@ class GravityZoneClient:
return self._jsonrpc_request("push", "sendTestPushEvent", params)
# ======================================================================
# PACKAGES (detail) read
# PACKAGES (detail) - read
# ======================================================================
def get_package_details(self, package_id: str) -> dict:
"""Installation package detail (packages.getPackageDetails). `packageId`
@@ -925,12 +926,12 @@ class GravityZoneClient:
) or {}
# ======================================================================
# REPORTS (create / delete) getReportsList + get_report_links above
# REPORTS (create / delete) - getReportsList + get_report_links above
# ======================================================================
def create_report(self, name: str, extra: Optional[dict] = None) -> Any:
"""Create a report (reports.createReport). `name` required (verified);
`type`, `targetIds`, recurrence/format etc. passed via `extra`.
STATE-CHANGING gate at the call site behind --confirm."""
STATE-CHANGING - gate at the call site behind --confirm."""
params: dict = {"name": name}
if extra:
params.update(extra)
@@ -938,19 +939,19 @@ class GravityZoneClient:
def delete_report(self, report_id: str) -> Any:
"""Delete a report (reports.deleteReport). `reportId` required (verified).
STATE-CHANGING gate at the call site behind --confirm."""
STATE-CHANGING - gate at the call site behind --confirm."""
return self._jsonrpc_request(
"reports", "deleteReport", {"reportId": report_id}
)
# ======================================================================
# QUARANTINE (remove / restore) getQuarantineItemsList above
# QUARANTINE (remove / restore) - getQuarantineItemsList above
# ======================================================================
def remove_quarantine_items(
self, quarantine_item_ids: list[str], extra: Optional[dict] = None
) -> Any:
"""Delete quarantined items (quarantine/computers.createRemoveQuarantineItemTask).
`quarantineItemsIds` required (verified). STATE-CHANGING gate behind --confirm."""
`quarantineItemsIds` required (verified). STATE-CHANGING - gate behind --confirm."""
params: dict = {"quarantineItemsIds": quarantine_item_ids}
if extra:
params.update(extra)
@@ -963,7 +964,7 @@ class GravityZoneClient:
) -> Any:
"""Restore quarantined items (quarantine/computers.createRestoreQuarantineItemTask).
`quarantineItemsIds` required (verified). `addExclusionInPolicy` etc. via
`extra`. STATE-CHANGING gate behind --confirm."""
`extra`. STATE-CHANGING - gate behind --confirm."""
params: dict = {"quarantineItemsIds": quarantine_item_ids}
if extra:
params.update(extra)
@@ -972,7 +973,7 @@ class GravityZoneClient:
)
# ======================================================================
# INCIDENTS custom rules + incident status/note (read + state-changing)
# INCIDENTS - custom rules + incident status/note (read + state-changing)
# ======================================================================
def list_custom_rules(self, page: int = 1, per_page: int = 100) -> dict:
"""List EDR custom rules (incidents.getCustomRulesList). VERIFIED LIVE."""
@@ -983,7 +984,7 @@ class GravityZoneClient:
def create_custom_rule(self, name: str, extra: Optional[dict] = None) -> Any:
"""Create an EDR custom rule (incidents.createCustomRule). `name` required
(verified); rule body (settings/companyId/tags) via `extra`.
STATE-CHANGING gate behind --confirm."""
STATE-CHANGING - gate behind --confirm."""
params: dict = {"name": name}
if extra:
params.update(extra)
@@ -991,15 +992,15 @@ class GravityZoneClient:
def delete_custom_rule(self, rule_id: str) -> Any:
"""Delete an EDR custom rule (incidents.deleteCustomRule). `ruleId` required
(verified). STATE-CHANGING gate behind --confirm."""
(verified). STATE-CHANGING - gate behind --confirm."""
return self._jsonrpc_request(
"incidents", "deleteCustomRule", {"ruleId": rule_id}
)
def change_incident_status(self, incident_type: str, fields: dict) -> Any:
"""Change an incident's status (incidents.changeIncidentStatus). `type`
required (verified) the incident type/category plus the incident id +
target status in `fields`. STATE-CHANGING gate behind --confirm."""
required (verified) - the incident type/category - plus the incident id +
target status in `fields`. STATE-CHANGING - gate behind --confirm."""
params: dict = {"type": incident_type}
params.update(fields or {})
return self._jsonrpc_request("incidents", "changeIncidentStatus", params)
@@ -1012,7 +1013,7 @@ class GravityZoneClient:
return self._jsonrpc_request("incidents", "updateIncidentNote", params)
# ======================================================================
# LICENSING (usage) + INTEGRATIONS read
# LICENSING (usage) + INTEGRATIONS - read
# ======================================================================
def get_monthly_usage(self) -> dict:
"""Monthly license usage (licensing.getMonthlyUsage). VERIFIED LIVE."""
@@ -1027,7 +1028,7 @@ class GravityZoneClient:
) or {}
# ======================================================================
# CACHE LAYER (identity / structure only never volatile status)
# CACHE LAYER (identity / structure only - never volatile status)
# ======================================================================
def _read_cache(self) -> Optional[dict]:
if not CACHE_FILE.exists():
@@ -1069,7 +1070,7 @@ class GravityZoneClient:
if cid:
companies_map[cid] = c.get("name", "")
# Endpoints per company (identity tier only no status fields).
# Endpoints per company (identity tier only - no status fields).
for cid in list(companies_map.keys()) + [ACG_ROOT_COMPANY_ID]:
page = 1
while True:
@@ -1130,7 +1131,7 @@ class GravityZoneClient:
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
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

View File

@@ -24,6 +24,9 @@ def run(args):
env = dict(os.environ)
env.setdefault("CLAUDETOOLS_ROOT", "C:/claudetools")
env["PYTHONIOENCODING"] = "utf-8"
# Never let the read-only self-test pollute errorlog.md: its intentional
# bad-id / no-confirm cases are EXPECTED, not skill failures.
env["GZ_SUPPRESS_ERRORLOG"] = "1"
p = subprocess.run([sys.executable, GZ] + args, capture_output=True,
text=True, env=env, timeout=120)
return p.returncode, p.stdout, p.stderr