Metadata-Version: 2.4
Name: g6-alert
Version: 1.0.0
Summary: Unified SDK and CLI for managing Guardsix alert rules (Standalone + Fleet)
Author: Guardsix Security Team
License-Expression: Apache-2.0
Keywords: guardsix,logpoint,siem,alert,detection,security,cli,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: g6-alert-core==1.0.0
Requires-Dist: requests<3,>=2.28.0
Requires-Dist: urllib3<3,>=1.26.0
Requires-Dist: PyJWT<3,>=2.8.0
Requires-Dist: click<9,>=8.1.0
Requires-Dist: pyyaml<7,>=6.0
Requires-Dist: tabulate<1,>=0.9.0
Requires-Dist: werkzeug<4,>=2.3.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff==0.15.20; extra == "dev"
Requires-Dist: mypy>=1.9.0; extra == "dev"
Requires-Dist: types-PyYAML>=6.0.0; extra == "dev"
Requires-Dist: types-requests>=2.28.0; extra == "dev"
Requires-Dist: types-tabulate>=0.9.0; extra == "dev"
Dynamic: license-file

# g6-alert — Unified Guardsix Alert-Rule SDK & CLI

> One CLI and one `AlertClient` facade for managing Guardsix alert (detection) rules
> across **Standalone** instances and **Fleet** consoles, selected by a typed profile.

![Python](https://img.shields.io/badge/python-3.11%2B-blue)
![Version](https://img.shields.io/badge/version-1.0.0-informational)
![License](https://img.shields.io/badge/license-Apache_2.0-blue)

---

## Table of Contents

- [Overview](#overview)
- [Features](#features)
- [Standalone vs. Fleet: what differs](#standalone-vs-fleet-what-differs)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [auth.json — Credential File Format](#authjson--credential-file-format)
- [CLI Usage](#cli-usage)
  - [Global Options](#global-options)
  - [Shared Commands](#shared-commands)
  - [Fleet-Only Commands](#fleet-only-commands)
  - [Config Management](#config-management)
- [Programmatic Usage](#programmatic-usage)
  - [Standalone (Library)](#standalone-library)
  - [Fleet (Library)](#fleet-library)
  - [Per-Target Results](#per-target-results)
- [API Reference](#api-reference)
  - [AlertClient](#alertclient)
  - [Target and TargetResult](#target-and-targetresult)
  - [Backend SDKs](#backend-sdks)
- [YAML Rule Validation](#yaml-rule-validation)
  - [Rule YAML — `minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version)
  - [Rule YAML — `detection.timerange`](#rule-yaml--detectiontimerange)
  - [Rule YAML — `notification.syslog`](#rule-yaml--notificationsyslog)
- [discovery.json — Fleet Target Resolution](#discoveryjson--fleet-target-resolution)
- [Distributed Guardsix Repo Resolution](#distributed-guardsix-repo-resolution)
- [Error Handling](#error-handling)
- [Architecture](#architecture)
- [Development & Testing](#development--testing)
- [Contributing](#contributing)
- [License](#license)

---

## Overview

**Guardsix** is a SIEM (Security Information and Event Management) platform used by security
operations teams to detect threats and investigate incidents. **Alert rules** (also called
detection rules) define the search queries, conditions, risk levels, and MITRE ATT&CK
mappings that trigger Guardsix incidents.

Guardsix is deployed in two topologies:

| Mode | Target | Auth | Topology |
|---|---|---|---|
| **Standalone** | Single Guardsix instance | Local self-signed JWT — no network token endpoint | One implicit target |
| **Fleet** | Fleet console → many pools of nodes | Remote Bearer token with `/refreshToken` refresh | Multi-target fan-out; async "order" API |

This package drives both from one `g6-alert` binary and one `AlertClient` facade. A
**profile** typed as `standalone` or `fleet` selects the transport and auth; the same
verbs (`list`, `create`, `activate`, …) work against either backend, though the two
modes differ in several important ways — see [Standalone vs. Fleet: what
differs](#standalone-vs-fleet-what-differs) below.

The shared domain layer (path-safe YAML loading, rule validation, pack manifests, exception
hierarchy, formatting) lives in the companion package `g6-alert-core`, which has no
HTTP, auth, or CLI dependencies.

The detection rules and packs this CLI installs are published separately at
**https://gitlab.com/guardsix-oss/detection-rules**; check that repo out anywhere on
disk and point `install-pack` / `pack-diff` at it.

---

## Features

- **One CLI, two backends.** `g6-alert` drives both Standalone and Fleet with the same
  verbs. Fleet-only commands are hidden from `--help` under a standalone profile.
- **Unified `AlertClient` facade** for backend-agnostic Python usage — swap profiles
  without changing application code.
- **Per-target resilience.** Fleet fan-out captures each target's outcome independently:
  success, error, or (for bulk activate/deactivate/delete) indeterminate, when a write's
  result couldn't be confirmed. One failing node doesn't abort the rest.
- **YAML rule definitions** — portable files with structured fields for query, risk,
  MITRE ATT&CK, incident display, notification, and metadata.
- **Detection-pack install & lifecycle.** `install-pack` applies an entire manifest of
  rules in one command (`--dry-run`, `--validate-only`, `--update-if-exists`, and
  Standalone-only `--atomic` rollback-on-failure). An existing rule found deactivated is
  reinstalled and reactivated automatically. `--prune`/`--prune-delete` deactivate, or
  (deprecated rules only, backed up) permanently delete, rules a pack no longer wants live.
- **`pack-diff`** previews a pack against one owner's deployment: missing, changed
  (including drifted active/inactive state), orphaned, deprecated-still-deployed, and
  ambiguous rules — the same computation `install-pack`/`--prune` act on.
- **Fleet topology awareness.** `discover` identifies Search-Head vs. Data-Node roles,
  plus two mutually-exclusive non-analytics node modes (Guardsix Collector, Raw Syslog
  Forwarder). Non-analytics nodes are skipped automatically by `install-pack` and rule
  operations. A Distributed Guardsix cluster (2+ Search Heads sharing the same Data
  Nodes) needs `--sh-target` to disambiguate, but only for the three commands that place
  a rule (`create`, `update`, `install-pack`) — every other command fans out across the
  whole cluster and acts wherever the rule actually exists.
- **MITRE ATT&CK resolution** on both backends, by different means: Fleet queries its
  own live catalog; Standalone maps technique IDs through a local CSV file. Both fall
  back from a sub-technique to its parent (`T1059.001` → `T1059`). See
  [Authentication](#authentication) for details.
- **Pre-mutation backups.** Fleet `delete` (and optionally `update`) snapshots each rule
  before modification; `restore` recreates a rule from any snapshot. Standalone has no
  backup mechanism.
- **Path-traversal-safe YAML loading** — `O_NOFOLLOW` + `secure_filename`, symlink
  rejection, and containment anchored to the file's own directory (`create`/`update`) or
  the pack's content-root (`install-pack`), not the current working directory. Rule
  files can live in a Rules repo checked out anywhere on disk.
- **Automatic token refresh.** Standalone re-mints its local JWT within a 5-minute
  safety margin; Fleet retries `POST /refreshToken` on a 401 when a `secret_key` is
  available.

---

## Standalone vs. Fleet: what differs

Both modes implement the same CLI verbs, but their underlying APIs are not
equivalent. This table is the authoritative summary; each item links to where it's
covered in full.

**Fleet-only commands** (hidden from `--help` under a standalone profile — see
[Fleet-only commands](#fleet-only-commands)): `discover`, `get-users`, `token-check`,
`refresh-token`, `backup-list`, `restore`.

**Shared commands with behavior gaps:**

| Command | Gap |
|---|---|
| `create` / `update` | `--owner`/`--owner-id` are Fleet-only. A Standalone SIEM API token is bound to a single user, so there is no other user to act on behalf of and owner selection does not apply. YAML `assigned_to` and `manageable_by` work on both backends, but each expects a different value type: Standalone takes a name (sent as-is) and rejects an ID; Fleet takes a name (resolved to an ID via `Users`/`IncidentUserGroups`) or an ID (used as-is), and rejects an unresolvable name. See [`create`](#create-yaml_file--create-a-rule-from-a-yaml-file). |
| `create` / `update` | MITRE `mitre:` entries resolve via Fleet's live per-node catalog API on Fleet, but via a local operator-supplied CSV (`mitre_map_file`) on Standalone — there is no catalog endpoint to query. See [Standalone — MITRE ATT&CK attack tags](#standalone--mitre-attck-attack-tags). |
| `create` / `update` | `minimum_guardsix_version` is enforced on Fleet (via `MachineInfo`) and inert on Standalone, which has no version endpoint. See [`minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version). |
| `create` / `update` | YAML `notification.syslog` is honored on Fleet; Standalone has no Syslog Notification API and silently skips it. Both backends support `notification.email`. |
| `update`, `delete`, `install-pack --prune-delete` | Fleet snapshots the rule first and can `restore` it; Standalone has no backup mechanism at all (`--prune-delete` requires `--acknowledge-no-backup` there). See [Pre-mutation backups](#features). |
| `install-pack` | `--atomic` (rolls back only this run's created rules, not updates) is Standalone-only. `--dry-run-json` and `--exists-scope` are Fleet-only. |
| `pack-diff` | The `VERSION` diff category (a `minimum_guardsix_version` mismatch) only applies on Fleet. |
| `list`, `get`, `activate`/`deactivate`, `delete`, `pack-diff` | `--status`, `--scope`, and the Fleet target flags (`--pool`, `--node`, `--discovery-file`) are Fleet-only; Standalone rejects them if passed explicitly, since it has exactly one implicit target. |

**Other API-level differences**, covered where relevant below: repo resolution
(Standalone detects Search-Head status live via `fetch_repos`; Fleet reads a persisted
role model from `discovery.json` — see [Distributed Guardsix Repo
Resolution](#distributed-guardsix-repo-resolution)), and token lifecycle (Standalone
mints a local JWT; Fleet holds a remote Bearer token with `secret_key`-based refresh —
see [Authentication](#authentication)).

---

## Requirements

- **Python 3.11 or newer** — both `g6-alert` and `g6-alert-core` declare
  `requires-python = ">=3.11"`; CI tests 3.11, 3.12, and 3.13.
- Runtime dependencies (installed automatically):
  `requests`, `urllib3`, `PyJWT`, `click`, `pyyaml`, `tabulate`, `werkzeug`,
  `g6-alert-core`.

---

## Installation

### From PyPI

```bash
pip install g6-alert
```

This installs `g6-alert-core` automatically as a pinned dependency
(`g6-alert-core==1.0.0`).

### From source (development)

Install both packages editable, from the repo root:

```bash
pip install -e ./g6_alert_core -e ./g6-alert
```

This installs `g6-alert-core` first (editable) and then `g6-alert`
(editable, which satisfies its `g6-alert-core` requirement from the local wheel).

**Development extras** (pytest, pytest-cov):

```bash
pip install -e "./g6-alert[dev]"
```

**Verify the install:**

```bash
g6-alert --version
```

---

## Quick Start

### Standalone

```bash
# 1. Create a profile
g6-alert config add lab \
  --type standalone \
  --base-url https://guardsix.example.com \
  --subject admin \
  --secret my-signing-secret \
  --default

# 2. List alert rules
g6-alert list

# 3. Create a rule from YAML
g6-alert create rules/my_detection.yaml

# 4. Activate / deactivate by alert ID
g6-alert activate 64abc123def456
g6-alert deactivate 64abc123def456
```

### Fleet

```bash
# 1. Create a Fleet profile
g6-alert config add prod \
  --type fleet \
  --fleet-url https://fleet.example.com \
  --access-token <token> \
  --secret-key <secret> \
  --default

# 2. Discover pools and nodes (writes discovery.json)
g6-alert --profile prod discover

# 3. List rules across a pool
g6-alert --profile prod list --pool SOC-Pool

# 4. Install a detection pack
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --owner-id 42
```

---

## Configuration

### Config file

The configuration file is YAML **v1** format, stored at:

```
~/.g6/config.yaml
```

Override the path via `--config <path>` or the `G6_ALERT_CONFIG` environment variable.
The file is written with `0o600` permissions (owner-read/write only).

Config, credential (`auth.json`), and backup-index files are all written atomically,
so a crash or a full disk mid-write can't corrupt them. There is no locking between
processes, though. Avoid running two `g6-alert` invocations against the same profile
at the same time. A concurrent Fleet token refresh in particular can invalidate a
sibling process's still-valid token.

### Full v1 schema

```yaml
version: 1
default_profile: prod-fleet

profiles:
  # Fleet profile
  prod-fleet:
    type: fleet
    fleet_url: https://fleet.example.com
    access_token: <access-token>
    secret_key: <secret-key>
    verify_ssl: true
    discovery_file: ~/.g6/discovery.json   # optional; defaults to ~/.g6/discovery.json
    default_pool: SOC-Pool
    default_node: lp-001   # optional; rarely needed
    auth_file: auth.json       # optional; stores/loads credentials from a JSON file
    pools:                     # optional; per-pool and per-node owner_id overrides
      SOC-Pool:
        owner_id: "42"
        nodes:
          lp-001:
            owner_id: "99"

  # Standalone profile
  lab-standalone:
    type: standalone
    base_url: https://10.45.16.23
    subject: admin
    secret: <signing-secret>
    verify_ssl: false
    auth_file: ~/.g6/lab-auth.json   # optional
    mitre_map_file: ~/.g6/mitre_map.csv   # optional; see "Standalone — MITRE ATT&CK attack tags"
```

**Profile selection order:**
1. `--profile NAME` CLI flag
2. `G6_PROFILE` environment variable
3. `default_profile` in the config file

### Config management commands

```bash
# Create or overwrite a profile
g6-alert config add NAME --type fleet|standalone [options...]

# List all profiles
g6-alert config list

# Show the active profile (secrets masked as abcd…wxyz)
g6-alert config show

# Change the default profile
g6-alert config set-default NAME

# Delete a profile (prompts for confirmation; --yes skips the prompt)
g6-alert config delete NAME [--yes]
```

The first profile ever added becomes the default automatically; use `--default` to
make a later one the default instead. Deleting the current default profile clears
`default_profile` without picking another automatically — run `config set-default`
afterward.

`config add` key options:

| Flag | Backend | Description |
|---|---|---|
| `--type` | both | `fleet` or `standalone` (required) |
| `--fleet-url` | fleet | Fleet console base URL |
| `--access-token` | fleet | Initial Bearer token |
| `--secret-key` | fleet | Token refresh secret |
| `--discovery-file` | fleet | Default path for `discovery.json` |
| `--default-pool` | fleet | Pool used when `--pool` is omitted |
| `--default-node` | fleet | Node used when `--node` is omitted |
| `--base-url` | standalone | Guardsix instance URL |
| `--subject` | standalone | Auth subject (username) |
| `--secret` | standalone | JWT signing secret |
| `--mitre-map-file` | standalone | Path to the MITRE technique->hash mapping CSV |
| `--verify-ssl` / `--no-verify-ssl` | both | TLS verification (default: on) |
| `--auth-file` | both | Path to external `auth.json` credential file |
| `--default` | both | Make this the default profile |
| `--force` | both | Overwrite an existing profile |

`config add --verify-ssl`/`--no-verify-ssl` sets the value stored in the profile. A separate,
global `--tls-verify`/`--no-tls-verify` flag is available on every command (e.g.
`g6-alert --no-tls-verify list ...`) to override TLS verification for a single run without
editing the profile. Resolution order: global CLI flag > `G6_VERIFY_SSL`
(fleet also checks `G6_FLEET_VERIFY_SSL` first) > profile value.

---

## Authentication

### Credential precedence

Credentials are resolved in this order (most specific wins):

```
explicit override (programmatic)  >  environment variable (G6_*)  >  profile value (config.yaml)
```

The CLI exposes no per-credential flags, so the "explicit override" tier applies to
library usage — arguments passed to `build_client(..., overrides=...)` or a backend
SDK constructor. For CLI users, credentials come from the `G6_*` environment
variables or the active profile.

A profile can instead point at an
[`auth.json`](#authjson--credential-file-format) via `auth_file`; when set and
loadable, that file supplies the credentials, superseding the profile's inline
fields (any field it omits falls back to the matching `G6_*` env var).

### Standalone — local self-signed JWT

- **No server token endpoint** — tokens are generated locally via PyJWT.
- Algorithms: `HS256`, `HS384`, `HS512` (default `HS256`). RS/ES variants are rejected to
  prevent algorithm-confusion attacks.
- **Token TTL:** 1 hour. Re-minted automatically when less than 5 minutes remain on the
  current token, or immediately on a 401 response.
- **Credential fields:** `base_url`, `subject`, `secret`.
- **Environment variables:**

  | Variable | Field |
  |---|---|
  | `G6_BASE_URL` | Base URL |
  | `G6_SUBJECT` | Subject (username) |
  | `G6_SECRET` | Signing secret |
  | `G6_VERIFY_SSL` | TLS verification |
  | `G6_MITRE_MAP_FILE` | Path to the MITRE technique->hash mapping CSV |

  `G6_VERIFY_SSL` accepts `true`, `on`, `yes`, or `1` (case-insensitive) to enable verification;
  anything else disables it. It overrides the profile's `verify_ssl` value.

- **Security notes:** a warning is printed if the base URL uses plain HTTP (credentials
  would be transmitted in cleartext); credential files are expected to be `0o600`.

### Standalone — MITRE ATT&CK attack tags

The standalone server has no MITRE catalog endpoint (unlike Fleet, which resolves attack
tags via a live per-pool/node lookup — see below). Instead, a CSV maps each MITRE
technique ID to the hash the server's own `taxonomy.attack_tag_hashes` field expects:

```csv
# MITRE ATT&CK Enterprise v18 (bundled with g6-alert-core)
hash,attack_id,attack_tag,attack_category
94711e5d399c28939c688f086b7faf85,"T1595","Active Scanning","Reconnaissance"
a846061737d21c675dcda4c7f087bc83,"T1595.001","Scanning IP Blocks","Reconnaissance"
```

A copy of this CSV, generated from MITRE ATT&CK Enterprise v18, ships with the tool, so
attack tags resolve out of the box after install. Resolution order:

1. `--mitre-map-file` / `G6_MITRE_MAP_FILE` / `profile.mitre_map_file`, if set — any
   explicit absolute path is accepted (not restricted to CWD or `~/.g6`); only a relative
   path containing a `..` segment is rejected.
2. `~/.g6/mitre_map.csv` (co-located with the config file), if present — drop your own
   file here to override the bundled copy without touching config.
3. Otherwise, the CSV bundled with the package.

- Resolution mirrors Fleet's own sub-technique fallback: an exact `attack_id` match wins;
  otherwise a sub-technique (`T1595.001`) falls back to its parent (`T1595`) if the parent
  is in the CSV.
- A rule with no `mitre:` section never touches the CSV. A missing/misconfigured CSV, or
  an unresolvable technique ID, degrades gracefully — the `create`/`update` still succeeds,
  and the CLI prints a `⚠️` warning naming the technique instead of failing the operation.

### Fleet — remote Bearer token with refresh

- **Header:** `Authorization: Bearer <access_token>` on every request.
- **Refresh:** `POST {fleet_url}/refreshToken` with body `{"secretKey": "<secret_key>"}`.
  Returns `{"tokenDetails": {"token": ..., "secretKey": ...}}`. A 401 with no `secret_key`
  present surfaces immediately to the caller.
- **Token age lifecycle** (surfaced by `token-check`):

  | Age | Status |
  |---|---|
  | < 4 h | `ok` |
  | 4 – 8 h | `monitor` |
  | ≥ 8 h | `expired` — renew with `refresh-token` (also auto-refreshes on a 401) |

- **Environment variables:**

  | Variable | Field |
  |---|---|
  | `G6_FLEET_URL` | Fleet console URL |
  | `G6_FLEET_ACCESS_TOKEN` | Bearer token |
  | `G6_FLEET_SECRET_KEY` | Refresh secret |
  | `G6_FLEET_VERIFY_SSL` | TLS verification |
  | `G6_VERIFY_SSL` | TLS verification (checked if `G6_FLEET_VERIFY_SSL` is unset) |

- **Security notes:** a warning is printed if the Fleet URL uses plain HTTP (the access token
  would be transmitted in cleartext); credential files are expected to be `0o600`.

---

## auth.json — Credential File Format

`auth.json` is an **alternative to inline profile credentials** — instead of
putting `fleet_url`/`access_token`/`secret_key` (or `base_url`/`subject`/`secret`)
directly in a profile, point the profile at a JSON file that holds them. A profile
opts in via its `auth_file:` field (or `--auth-file` when creating one with
`config add`); there is no environment variable for the file's path. Default
filename: `auth.json`. Both backends write it atomically at mode `0o600`, and Fleet
and Standalone use **different schemas** — pick the one matching your profile's
`type`.

### Fleet

```json
{
  "fleet_url": "https://fleet.example.com",
  "access_token": "eyJhbGciO...",
  "secret_key": "ab1979e1-1234-4dff-9725-b900c43502d3",
  "token_created_at": "2026-07-10T09:00:00.000000",
  "token_last_updated": "2026-07-14T03:15:00.000000"
}
```

| Field | Required | Written by | Description |
|---|---|---|---|
| `fleet_url` | yes | you | Fleet console URL |
| `access_token` | yes | you / SDK | Bearer token |
| `secret_key` | no | you / SDK | Refresh secret for `refresh-token` / auto-refresh-on-401 |
| `token_created_at` | — | SDK-managed | ISO-8601; set only when `access_token` changes; drives `token-check`'s age lifecycle |
| `token_last_updated` | — | SDK-managed | ISO-8601; rewritten on every save |

Loading fails with `AuthenticationError` unless both `fleet_url` and
`access_token` are present. `token_created_at`/`token_last_updated` are managed
entirely by the SDK — hand-written files can omit them.

### Standalone

```json
{
  "base_url": "https://standalone.example.com",
  "subject": "svc-alert-sdk",
  "secret": "a-strong-signing-secret",
  "created_at": "2026-07-14T03:15:00.000000"
}
```

| Field | Required | Written by | Description |
|---|---|---|---|
| `base_url` | yes | you | Standalone server base URL |
| `subject` | yes | you | JWT subject (username) |
| `secret` | yes | you | JWT signing secret |
| `created_at` | — | SDK-managed | ISO-8601, set on save |

Loading fails with `AuthenticationError` unless `base_url`, `subject`, **and**
`secret` are all present. Unlike Fleet, **no token is stored** — Standalone mints
a short-lived JWT locally at runtime and keeps it in memory only, so there's
nothing to refresh or persist beyond the signing secret.

### File permissions & source precedence

Both backends write `auth.json` atomically (`tempfile` + `os.chmod(0o600)` +
`os.replace`) so a crash mid-write can't corrupt it or leave it world-readable.
On load, a warning (not a failure) is printed if the file is already group- or
world-readable.

When a profile sets `auth_file` and the file loads successfully, its credentials
are authoritative for that profile — they supersede the profile's inline fields.
Any field the file doesn't define falls back to the matching `G6_*` environment
variable (see [Credential precedence](#credential-precedence)). To avoid
surprises, pick **one** credential source per profile — either inline fields, or
`auth_file` — rather than mixing both.

---

## CLI Usage

### Global options

```
g6-alert [OPTIONS] COMMAND [ARGS]...

Options:
  --profile TEXT                  Config profile name (else G6_PROFILE / default_profile)
  --config PATH                   Path to config file (else ~/.g6/config.yaml)
  --tls-verify / --no-tls-verify  Verify TLS certificates for this run (else
                                   G6_VERIFY_SSL / profile)
  -v, --verbose                   Enable verbose/debug logging
  --version                       Show version and exit
  --help                          Show this message and exit
```

`--tls-verify`/`--no-tls-verify`, like `--profile`/`--config`/`-v`, works before or after the
subcommand name (e.g. both `g6-alert --no-tls-verify list` and `g6-alert list --no-tls-verify`).

Fleet-only commands (`discover`, `get-users`, `backup-list`, `restore`, `token-check`,
`refresh-token`) are **hidden from `--help`** when the active profile is `standalone`.

Most commands accept these **Fleet target flags**:

| Flag | Description |
|---|---|
| `--pool TEXT` | Pool name or UUID (repeatable) |
| `--node TEXT` | Node identifier (repeatable) |
| `--discovery-file PATH` | Path to `discovery.json` |

They only make sense for Fleet. Under a standalone profile the CLI **rejects** any
of them if explicitly passed on the command line, with a usage error — they are not
silently ignored. The same applies to every other Fleet-only flag documented below
(`--owner`, `--owner-id`, `--scope`, `--status`, `--no-backup`, `--sh-target`,
`--exists-scope`, `--dry-run-json`).

`--sh-target TEXT` (repeatable/comma-separated) disambiguates a multi-Search-Head
Distributed Guardsix cluster. It's accepted, and required when a cluster is
ambiguous, only by `create`, `update`, and `install-pack` — the three commands that
place a new rule and so need to know which Search Head should host it. Every other
Fleet command resolves targets across all Search Heads in the cluster with no flag
needed, since there's no placement decision to make when operating on rules that
already exist.

---

### Shared commands

#### `list` — List alert rules

```bash
g6-alert list [OPTIONS]

Options:
  --search TEXT         Filter by name/content
  --status TEXT         Filter by status (Fleet)
  --risk [critical|high|medium|low]
  --active [all|true|false]   Default: all
  --scope TEXT          my / shared / vendor / used (Fleet)
  --json-output         Print full JSON instead of a table
  --show-owner          Add an Owner column (Fleet: resolves username from owner ID;
                        Standalone: shows the inline user field)
  [target flags]
```

```bash
# List all rules in a pool
g6-alert --profile prod list --pool SOC-Pool

# List only high-risk active rules
g6-alert list --risk high --active true

# Search by name, full JSON output
g6-alert list --search "PowerShell" --json-output
```

#### `get ALERT_ID` — Show one alert rule

```bash
g6-alert get ALERT_ID [--scope TEXT] [--json-output] [target flags]
```

```bash
g6-alert get 64abc123def456
g6-alert --profile prod get 64abc123def456 --pool SOC-Pool --json-output
```

#### `create YAML_FILE` — Create a rule from a YAML file

```bash
g6-alert create YAML_FILE [--owner-id TEXT] [--owner TEXT] [--repo TEXT]... \
         [--sh-target TEXT]... [--update-if-exists] [--dry-run] [target flags]
```

`--owner` resolves a username to an owner ID per node (Fleet; mutually exclusive with `--owner-id`).
`--sh-target` picks which Search Head(s) should host the rule in a multi-SH
Distributed Guardsix cluster (Fleet; repeatable/comma-separated) — required only
when 2+ Search Heads of the same cluster are targeted.

`--update-if-exists` only overwrites an alert whose `rule_id` tag confirms it's the
same rule. A same-named alert with no matching `rule_id` tag is never overwritten;
`create` attempts a fresh create instead, which the SIEM's own per-owner name
uniqueness check resolves to a clean skip if the name is already taken. The skip
message explains why (`identity_warnings`).

A rule YAML may also declare `assigned_to: <username or ID>` (the one user who can
re-assign, comment on, view, and **resolve** incidents this rule generates) and
`manageable_by: [<group name or ID>, ...]` (incident user groups whose members can
re-assign, comment on, and view, but not resolve, incidents). Both are optional. A
value is either a name or an already-resolved ID (a 24-character lowercase hex
string, e.g. `5a467b3dd8aaa461c3139038`), but which one is accepted depends on the
backend.

On **Fleet**, a name is resolved live the same way `--owner` resolves a username,
and an ID is used as-is with no lookup. An unresolvable name **fails the
create/update closed** (raises) rather than silently creating a rule with a gap in
who can act on its incidents. A pack manifest may set both pack-wide via
`pack.assigned_to`/`pack.manageable_by`, see
[`install-pack [PACK_ID]`](#install-pack-pack_id--install-a-detection-pack).

On **Standalone**, only a name is accepted. There's no Users/IncidentUserGroups
lookup endpoint to convert an ID into a name, so an ID value **fails closed**
(raises) rather than being sent to the server unresolved. A name is sent as-is. If
either field is absent, the incident assignee defaults to the profile's auth
`subject` and no incident user groups are granted visibility.

A rule's `notification.email` section is honored on both backends. A
`notification.syslog` section is Fleet-only — Standalone has no Syslog Notification
API and silently skips it. After deploying rules to a Standalone SIEM, use the SIEM
UI's bulk syslog notification update feature to apply the required syslog notification
template to them. See [Rule YAML — `notification.syslog`](#rule-yaml--notificationsyslog)
for how the syslog message body is built.

A rule YAML may also declare `minimum_guardsix_version: <X.Y.Z>` — Fleet blocks
(raises) the create if the target node's actual version doesn't meet it, or
can't be determined at all. Fleet-only; see
[Rule YAML — `minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version).

`YAML_FILE` may live anywhere on disk — e.g. a Rules repo checked out separately from
this SDK/CLI — on both backends; path-safety containment anchors to the file's own
directory, not the current working directory.

```bash
g6-alert create rules/my_detection.yaml
g6-alert --profile prod create rules/my_detection.yaml \
         --pool SOC-Pool --owner-id 42
```

#### `update ALERT_ID YAML_FILE` — Update an existing rule

```bash
g6-alert update ALERT_ID YAML_FILE [--owner-id TEXT] [--owner TEXT] \
         [--repo TEXT]... [--sh-target TEXT]... [--dry-run] [target flags]
```

`--sh-target` picks which Search Head(s) should host the rule in a multi-SH
Distributed Guardsix cluster (Fleet; repeatable/comma-separated) — required only
when 2+ Search Heads of the same cluster are targeted. Same `minimum_guardsix_version`
gate as `create`. `YAML_FILE` is subject to the same anywhere-on-disk path handling
described under `create` above.

#### `activate` / `deactivate` — Enable or disable rules

```bash
g6-alert activate [TOKENS...] [OPTIONS] [target flags]
g6-alert deactivate [TOKENS...] [OPTIONS] [target flags]

Options:
  --by-name            Match by rule name instead of ID
  --by-pack-tags       Treat positional arguments as pack tags; ALL must match
                       (AND logic). Mutually exclusive with --by-name.
  --owner-id TEXT      Alert owner ID (Fleet; required with --by-name/--by-pack-tags)
  --owner TEXT         Alert owner username (Fleet; resolved to owner_id per node;
                       required with --by-name/--by-pack-tags)
  --scope TEXT         Scope for name/tag resolution (Fleet)
  --yes                Skip confirmation prompt
  --dry-run            Preview without applying
```

`--by-name` and `--by-pack-tags` work on both backends (Standalone fetches each
alert's full detail to match names/tags client-side); `--scope`, `--owner-id`, and
`--owner` are Fleet-only. Fleet requires one of `--owner-id`/`--owner` whenever
`--by-name`/`--by-pack-tags` is used, so a name or tag match never crosses into
another user's rules.

With `--by-pack-tags`, if the given tokens resolve to an empty tag set (e.g. blank/
whitespace arguments), the command is rejected with a usage error before any network
call — an empty tag set would otherwise match every alert.

```bash
# By alert ID
g6-alert activate 64abc123def456

# By name
g6-alert deactivate "AADInternals PowerShell Detection" --by-name

# By pack tags (Fleet: deactivate all rules tagged day-1 AND customerA, owned by 42)
g6-alert --profile prod deactivate day-1 customerA \
         --by-pack-tags --pool SOC-Pool --owner-id 42 --yes
```

#### `delete [TOKENS...]` — Permanently delete rules

```bash
g6-alert delete [TOKENS...] [OPTIONS] [target flags]

Options:
  --by-name            Match by rule name
  --by-pack-tags       Match by pack tag; ALL must match (AND logic)
  --owner-id TEXT      Alert owner ID (Fleet; required with --by-name/--by-pack-tags)
  --owner TEXT         Alert owner username (Fleet; resolved to owner_id per node;
                       required with --by-name/--by-pack-tags)
  --scope TEXT         Scope for name/tag resolution (Fleet)
  --yes                Skip confirmation
  --no-backup          Skip pre-delete backup (Fleet only)
  --max-delete N       Safety cap: abort before deleting anything if the
                       matched/given alert count exceeds N
  --dry-run            Preview without deleting
```

As with `activate`/`deactivate`, `--by-name` and `--by-pack-tags` work on both
backends (`--scope`, `--owner-id`, and `--owner` are Fleet-only, and one of
`--owner-id`/`--owner` is required alongside them there), and an empty resolved
`--by-pack-tags` set is rejected with a usage error before any network call.

```bash
# Preview, then confirm delete
g6-alert --profile prod delete 64abc123def456 --pool SOC-Pool --dry-run
g6-alert --profile prod delete 64abc123def456 --pool SOC-Pool --yes
```

On Fleet, a backup snapshot is created before each deletion unless `--no-backup` is
given. Use `backup-list` and `restore` to recover. Standalone has no backup
mechanism, so `delete` there is not recoverable.

Deleting an alert that no longer exists succeeds as a no-op: the command reports it
as already gone rather than as an error, so `delete` is safe to rerun.

#### `pack-diff [PACK_ID]` — Preview a pack vs. a deployment

```bash
g6-alert pack-diff [PACK_ID] [OPTIONS] [target flags]

Options:
  --pack TEXT          Pack ID (alternative to positional arg)
  --packs-dir PATH     Root of packs directory (default: Packs/)
  --content-root PATH  Root that manifest rule paths resolve against
                       (default: the parent of --packs-dir)
  --owner-id TEXT      Owner to diff against (Fleet; mandatory, mutually
                       exclusive with --owner)
  --owner TEXT         Owner username to diff against (Fleet; mandatory)
  --scope TEXT         Scope used to read deployed alerts (Fleet)
  --json-output        Emit the diff as JSON instead of tables
```

```bash
g6-alert pack-diff day-1
g6-alert --profile prod pack-diff day-1 --pool SOC-Pool --owner-id 42
```

Strictly read-only: never creates, updates, deletes, or deactivates anything. Fleet
allows multiple owners to each run their own independent copy of a rule, so the diff
is always scoped to a single owner (the same unit of work `install-pack` uses). For
each target it reports:

- **PRESENT** — in the pack and deployed under this owner, matching.
- **MISSING** — in the pack, not deployed under this owner.
- **CHANGED** — deployed but differs from the pack. Causes include
  name/description/risk/query drift, a name match whose `rule_id` diverges (an
  unmanaged rule squatting the name), a drifted active state, or a repo drift, e.g.:
  `CHANGED  • Activity Related to NTDS Domain Hash Retrieval (id=...) changed:
  active (currently INACTIVE — pack expects it active)`.
  `install-pack`'s existing-rule reconciliation auto-fixes a drifted active state, but
  only for a confirmed `rule_id` match. A name-only match (no `rule_id` tag on the
  deployed alert) is never reinstalled or reactivated automatically; it's treated as a
  fresh rule instead, the same as no match at all. Reconciliation does not act on a
  repo-only change — an already-deployed active rule is always skipped there unless
  `--update-if-exists` is passed. Repo is only compared when the pack pins one
  explicitly (the manifest rule entry's own `repo:`, or failing that the rule YAML
  file's own top-level `repo:`); a rule that never pins a repo is never flagged just
  because the live repo catalog or the deployed alert's auto-fetched repos drift over
  time.
- **ORPHANED** — deployed under this owner, carries this pack's tags, no longer in
  the pack manifest.
- **DEPRECATED** — the pack marks this rule `status: deprecated` and it's still
  deployed under this owner (scheduled for removal, not yet cleaned up).
  `install-pack --prune`/`--prune-delete` only acts on a confirmed `rule_id` match; a
  name-only match is held back and reported as `NEEDS REVIEW` instead, never
  deactivated or deleted automatically.
- **AMBIGUOUS** — this owner has 2+ alerts sharing a `rule_id`; none are classified
  until the duplicates are resolved.
- **VERSION** — Fleet only. The rule declares `minimum_guardsix_version` and this
  target's Guardsix version doesn't meet it, or couldn't be determined. This is a
  flag, not a block, and can appear alongside any other category above, since
  `pack-diff` never blocks anything. See
  [Rule YAML — `minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version).

Use `install-pack --prune`/`--prune-delete` to deactivate or delete DEPRECATED/
ORPHANED rules, or `delete --by-pack-tags` to remove them yourself.

#### `install-pack [PACK_ID]` — Install a detection pack

```bash
g6-alert install-pack [PACK_ID] [OPTIONS] [target flags]

Options:
  --pack TEXT           Pack ID (alternative to positional arg)
  --packs-dir PATH      Root of packs directory (default: Packs/)
  --content-root PATH   Root that manifest rule paths resolve against
                        (default: the parent of --packs-dir)
  --owner-id TEXT       Rule owner ID (Fleet; mutually exclusive with --owner)
  --owner TEXT          Rule owner username, resolved to an owner ID per node
                        (Fleet; mutually exclusive with --owner-id)
  --repo TEXT           Repo address (repeatable; both backends)
  --sh-target TEXT      Restrict installation to specific Search Head(s) in a
                        multi-SH Distributed Guardsix cluster (Fleet; repeatable)
  --exists-scope TEXT   Scope used when checking if a rule already exists (Fleet)
  --update-if-exists    Update instead of skipping existing rules
  --dry-run             Preview CREATE/UPDATE/SKIP against the live deployment
                        (server-assisted — schema validation still runs, so
                        a dry-run surfaces the same errors a real apply would)
  --validate-only       Validate the manifest without installing
  --offline             Fully offline dry-run — no API calls at all, including
                        the existence check, so it can only preview CREATE
                        (implies --dry-run)
  --dry-run-json        Output dry-run plan as JSON (Fleet)
  --atomic              Roll back this run's created alerts if any rule
                        fails (Standalone only)
  --prune               After installing, deactivate (never delete)
                        deprecated-and-still-deployed and orphaned
                        (removed-from-manifest) rules. Prompts unless --yes.
  --prune-delete        Escalate --prune's action on deprecated rules to a
                        permanent delete (requires --prune)
  --no-backup           Skip the pre-delete backup for --prune-delete
                        (Fleet only)
  --acknowledge-no-backup
                        Required with --prune-delete on Standalone, which has
                        no backup mechanism
  --yes                 Skip the --prune confirmation prompt
```

`--packs-dir` isn't restricted to the current working directory: the manifest can be
anywhere on disk. Every rule `path:` in the manifest resolves against a content root,
which defaults to the parent of `--packs-dir` — so a pack and the Rules it references
can live together in a separately released repo checked out anywhere on disk, as long as
they share that parent. Pass `--content-root` to put `--packs-dir` and the rule tree in
separate locations; manifest `path:` entries stay unchanged, only the directory they
resolve against moves.

```bash
# Default: Packs and Rules share a parent
g6-alert install-pack day-1 --packs-dir /srv/g6-content/Packs
# rules resolve at /srv/g6-content/Rules/...

# Packs and Rules in separate trees
g6-alert install-pack day-1 --packs-dir /srv/packs --content-root /srv/detection-content
# rules resolve at /srv/detection-content/Rules/...
```

```bash
# Standalone
g6-alert install-pack day-1

# Standalone — rolls back this run's created rules if a later rule fails (updates aren't rolled back)
g6-alert install-pack day-1 --atomic

# Fleet — dry run first (server-assisted: reports CREATE/UPDATE/SKIP per rule
# against what's actually deployed; add --offline for a fully disconnected
# preview, which can only ever report CREATE)
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --dry-run
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --owner-id 42

# Preview, then deactivate deprecated/orphaned rules no longer in the pack
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --owner-id 42 --prune --dry-run
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --owner-id 42 --prune --yes

# Permanently delete just the deprecated subset (Fleet backs up automatically)
g6-alert --profile prod install-pack day-1 --pool SOC-Pool --owner-id 42 \
  --prune --prune-delete --yes

# Same, Standalone (no backup mechanism — must be explicitly acknowledged)
g6-alert install-pack day-1 --prune --prune-delete --acknowledge-no-backup --yes
```

The pack manifest (`Packs/<pack-id>/manifest.yaml`) lists rules by relative path. Fleet
auto-discovers repos, skips Data Nodes/Guardsix Collectors/Raw Syslog Forwarders, and
injects `pack_tags` metadata. Fleet also skips (per rule/target pair, with a visible
`skipped (requires Guardsix >= X (target is Y))` line) any rule whose
`minimum_guardsix_version` isn't met by that target — see
[Rule YAML — `minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version). Use
[`pack-diff`](#pack-diff-pack_id--preview-a-pack-vs-a-deployment) beforehand to preview
exactly what any of the below will do.

**Manifest per-rule fields.** Each entry in the manifest's `rules:` list supports these
fields:

| Field | Required | Type | Purpose |
|---|---|---|---|
| `path` | yes | string | Relative path to the rule YAML, resolved against the content root. |
| `repo` | no | list | Repo name(s) to place the rule on. Must be a YAML sequence — write `repo: [Windows]`, not `repo: Windows`. |
| `rule_id` | no | string | Stable ID used to match against deployed alerts. Falls back to the deployed alert's own `rule_id`, then the rule path, when omitted. |
| `status` | no (default `active`) | string | Lifecycle state. Only `active` or `deprecated` are allowed. Full removal is expressed by deleting the entry, not a third status. |
| `added_in` | no | string | Pack version the rule was introduced in. If the key is present it must be non-empty; omit it entirely rather than leaving it blank. |
| `deprecated_in` | yes when `status: deprecated` | string | Pack version the rule was deprecated in. |
| `deprecated_reason` | yes when `status: deprecated` | string | Reason for deprecation, surfaced in `--prune`/`--prune-delete` output. |

`status: deprecated` is fail-closed: it's rejected unless both `deprecated_in` and
`deprecated_reason` are present, so a rule never silently disappears from a pack without
an audited reason. Deprecated-and-still-deployed rules are what
[`pack-diff`](#pack-diff-pack_id--preview-a-pack-vs-a-deployment)'s `DEPRECATED` category
and `install-pack --prune`/`--prune-delete` act on. Pack-wide fields
(`assigned_to`/`manageable_by`) live in the top-level `pack:` section, not per rule — see
below.

**Existing-rule reconciliation (no flag needed).** Matching is by the rule's stable
`rule_id`. An existing rule that's still **active** is skipped
(`skipped '<name>' (exists, active)`). An existing rule found **inactive** is
automatically reinstalled from the on-disk YAML and reactivated, since a plain
create-or-skip install would otherwise never revisit it, but only for a confirmed
`rule_id` match; a name-only match (no `rule_id` tag on the deployed alert) is never
reinstalled or reactivated automatically, and a fresh create is attempted instead. This
reinstall-and-reactivate path can't be triggered by `--update-if-exists` alone: the
update payload built from the YAML carries no active/enabled field, so activation is
always a separate API call. It's reported as one line, `reinstalled '<name>' from disk
and activated (was inactive)`, and counted as a success, not a skip. `--dry-run`'s
prediction (`would CREATE`/`would UPDATE`/`would SKIP`) uses this same rule_id matching
and inactive-reinstalls-as-update logic, so the preview always matches what a real apply
does; `--offline` can't check what's deployed and always previews CREATE.
`--update-if-exists` remains available to force-refresh an already-active existing
rule's definition from disk.

**Pack-wide `assigned_to`/`manageable_by` (Fleet only).** A manifest's `pack:` section
may set `assigned_to: <username or ID>` and `manageable_by: [<group name or ID>, ...]`,
the same fields a rule YAML can declare individually (see
[`create YAML_FILE`](#create-yaml_file--create-a-rule-from-a-yaml-file)). When set, the
pack's value is resolved once per target and applied to **every** rule in the pack,
taking precedence over that rule's own YAML value. An empty/omitted pack value (the
default) sets no pack-wide default, so each rule's own value, if any, is used instead.
Resolution is fail-closed like the per-rule fields: an unresolvable pack-level name
fails every rule for that target rather than silently installing with a gap in who can
act on the resulting incidents.

**`--atomic` (Standalone only).** If any rule in the pack fails after one or more
rules have already been created, the CLI deletes the rules *created by this run*
before exiting; rules that were skipped (already existed) or updated are left
untouched. It never triggers under `--dry-run` or `--validate-only`. If a compensating
delete itself fails, the CLI prints `Rollback FAILED for <id>: ...` and a final `N
rollback(s) failed — manual cleanup may be required` warning instead of failing
silently.

Fleet's `install-pack` doesn't support `--atomic`: a single run fans out across every
resolved pool/node, so "roll back this run's creates" is ambiguous once some targets
have succeeded and others haven't, and a timed-out/unconfirmed async write leaves the
SDK unsure whether the rule was actually created, so there's no reliable ID to roll
back by. Fleet's safety net is the pre-mutation backup + `restore` flow instead
(`delete` snapshots each rule before removing it).

**`--prune`.** Removing a rule from the pack manifest and re-running `install-pack`,
even with `--update-if-exists`, does **not** touch the already-deployed copy on its
own — the install loop only ever iterates the manifest's own rules. `--prune` closes
that gap: after installing, it deactivates (never deletes) two categories —
`status: deprecated` rules still deployed, and rules dropped from the manifest
entirely but still carrying the pack's tags (orphaned). It prompts for confirmation
unless `--yes`, `--dry-run` prints the candidates without acting, and it's skipped
entirely under `--offline`/`--validate-only`.

**`--prune-delete`.** `--prune` only deactivates. `delete --by-pack-tags` can't
substitute for permanent cleanup here, since every rule in a pack shares the same
tags and a tag-based delete would match the entire pack, not just the deprecated
rules. `--prune-delete` escalates only the deprecated-and-still-deployed subset to a
permanent delete, reusing the same backup+delete path `delete` uses. Orphaned
(removed-from-manifest) rules are always just deactivated, never deleted here, since
that's a weaker signal than an explicit `status: deprecated`. Requires `--prune`. On
Fleet each deleted rule is backed up first (skip with `--no-backup`; restore with
`backup-list`/`restore`); Standalone has no backup mechanism at all, so
`--prune-delete` there additionally requires `--acknowledge-no-backup` or fails
closed with a usage error before any API calls.

---

### Fleet-only commands

> These commands are hidden from `--help` under a standalone profile.

#### `discover` — Map pools and nodes

```bash
g6-alert [--profile NAME] discover [-o OUTPUT]
```

Queries the Fleet REST API to enumerate pools and nodes, then probes each node's
`DistributedLogpoints` endpoint to label Search-Head and Data-Node roles, its
`SystemSettingsModesOfOperation`/`RawSyslogForwarder` endpoints to flag the two
mutually-exclusive non-analytics node modes (Guardsix Collector, Raw Syslog
Forwarder), and its `MachineInfo` endpoint for the node's running Guardsix version.
A node's mode is shown as a `(Guardsix Collector)`/`(Raw Syslog Forwarder)` suffix,
followed by a `[vX.Y.Z]` version suffix when it could be determined (e.g.
`- sh1 | host-sh1 [v7.8.0.2]`). Neither mode changes the node's
Search-Head/Data-Node/Standalone grouping in the printed output — the topology of
which Data Nodes or Search Heads a given collector/forwarder feeds isn't tracked,
only the mode itself. Results are written to the shared discovery cache at
`~/.g6/discovery.json` (permissions `0o600`), keyed by profile name so multiple
profiles coexist in one file.

Version display is Fleet-only; Standalone has no equivalent endpoint.

Output path resolution: `-o PATH` > `profile.discovery_file` > `~/.g6/discovery.json`.

```bash
# Use the default path (~/.g6/discovery.json)
g6-alert --profile prod discover

# Override the output path
g6-alert --profile prod discover -o /tmp/discovery.json
```

See [discovery.json](#discoveryjson--fleet-target-resolution) for the output schema.

#### `get-users` — List Fleet users (find owner IDs)

```bash
g6-alert --profile prod get-users --pool SOC-Pool
g6-alert --profile prod get-users --pool SOC-Pool --json-output
g6-alert --profile prod get-users --pool SOC-Pool --config-only  # prints owner_id snippet
```

Default output is a table (Pool / Node / Node ID / Username / User ID). `--json-output`
emits JSON instead; `--config-only` prints a YAML `owner_id` snippet for pasting into a
profile's `pools:` section.

#### `token-check` / `refresh-token` — Token lifecycle management

```bash
g6-alert --profile prod token-check    # reports token age and recommendation
g6-alert --profile prod refresh-token  # refreshes using secret_key
```

#### `backup-list` — List local rule backups

```bash
g6-alert --profile prod backup-list [--pool POOL] [--node LP] [--alert-id ID] [--json-output]
```

#### `restore` — Restore a rule from a backup

```bash
g6-alert --profile prod restore \
  --backup-file ~/.g6/backups/SOC-Pool/lp-001/64abc123def456/2026-06-01_12-00-00_123456.yaml \
  --pool SOC-Pool [--alert-id ID] --yes
```

Backups are written under `~/.g6/backups/<pool>/<node>/<alert_id>/<timestamp>.yaml`
(co-located with the config file; `backup-list` reads this same tree). `--backup-file`
isn't restricted to that tree, so restoring a copy brought in from another host is
supported, but a symlinked backup file is refused (guards against a symlink swap
between validation and read).

Restores by updating the rule if it still exists, or creating it if it was deleted.
Use `--dry-run` to preview without applying.

Restore reconstructs the rule from the backed-up alert's own API row, so every field
comes back exactly as it was: query, risk, condition, `rule_status`, pack tags, MITRE
`attack_tag` mappings, the incident data view's Jinja template, `assigned_to`/
`manageable_by`, and email/syslog notification config, including the exact rendered
message templates.

A create, update, or delete can fail after its pre-mutation backup was already
written, leaving the rule in whatever state that attempt left it. The backup from
just before that mutation is exactly what `restore` needs to put it back.

---

### Config management

```bash
g6-alert config add NAME [--type fleet|standalone] [options...]
g6-alert config list
g6-alert config show
g6-alert config set-default NAME
g6-alert config delete NAME [--yes]
```

See [Configuration](#configuration) for full option tables.

---

## Programmatic Usage

Import and build a client via the factory (recommended) or construct backend SDKs directly
for advanced use.

### Standalone (Library)

```python
from g6_alert.config import Profile
from g6_alert.factory import build_client
from g6_alert_core import AlertSDKError, AlertNotFoundError

profile = Profile(
    name="lab",
    type="standalone",
    base_url="https://guardsix.example.com",
    subject="admin",
    secret="my-signing-secret",
)
client = build_client(profile)  # -> AlertClient backed by StandaloneTransport

# List all rules
results = client.list_alerts()
for r in results:
    if r.ok:
        for rule in r.value:
            print(rule["name"], rule.get("status"))

# Create a rule (path-traversal-safe; return value contains server response)
results = client.create_alert("rules/my_detection.yaml")
assert results[0].ok, results[0].error

# Get by ID (raises AlertNotFoundError if missing)
try:
    results = client.get_alert("64abc123def456")
    detail = results[0].value
except AlertNotFoundError:
    print("Rule not found")

# Activate / deactivate / delete (by list of IDs)
client.activate(["64abc123def456"])
client.deactivate(["64abc123def456"])
client.delete(["64abc123def456"])

client.close()
```

### Fleet (Library)

```python
from g6_alert.config import Profile
from g6_alert.factory import build_client

profile = Profile(
    name="prod",
    type="fleet",
    fleet_url="https://fleet.example.com",
    access_token="<access-token>",
    secret_key="<refresh-secret>",
    discovery_file="~/.g6/discovery.json",
)
client = build_client(profile)  # -> AlertClient backed by FleetTransport

# List rules across all nodes in a pool
results = client.list_alerts(pools=["SOC-Pool"])
for r in results:
    print(f"{r.target.label}: {'ok' if r.ok else r.error}")
    if r.ok:
        for rule in r.value:
            print(f"  {rule['name']} [{rule.get('status')}]")

# Create — the async Fleet "order" is polled automatically via await_result
results = client.create_alert(
    "rules/my_detection.yaml",
    pools=["SOC-Pool"],
    owner_id="42",
)

# Filter to specific nodes
results = client.list_alerts(
    pools=["SOC-Pool"],
    nodes=["lp-001"],
    search="PowerShell",
    risk="high",
)

client.close()
```

**Backup is a CLI-only safety net.** The `g6-alert` CLI wraps a pre-mutation
backup around `update`, `delete`, `create --update-if-exists`, and
`install-pack`'s overwrite/prune-delete paths (see the `restore` command under
CLI Usage above). Calling `create_alert`/`update_alert`/`delete_alert`/
`bulk_delete_alerts` directly through this library, as in the example above,
does **not** create a backup — that protection only exists at the CLI layer. If
you're overwriting or deleting alerts programmatically and want the same safety
net, back up the existing alert yourself first
(`g6_alert.backends.fleet.backup.AlertBackupManager`) before calling these
methods.

### Per-Target Results

Every `AlertClient` method returns `List[TargetResult]`. Check each result individually:

```python
results = client.create_alert("rules/my_detection.yaml", pools=["SOC-Pool"])
for r in results:
    if r.ok:
        print(f"[{r.target.label}] created: {r.value}")
    else:
        print(f"[{r.target.label}] FAILED: {r.error}")
```

---

## API Reference

### AlertClient

**Import:** `from g6_alert_core.client import AlertClient`  
**Factory:** `from g6_alert.factory import build_client`

All methods return `List[TargetResult]`. Fleet calls fan out across resolved targets;
standalone always returns a single-element list.

| Method | Key parameters | Description |
|---|---|---|
| `list_alerts` | `pools`, `nodes`, `discovery_file`, `search`, `status`, `risk`, `active`, `scope`, `limit` | List rules with optional filters |
| `get_alert` | `alert_id`, `pools`, `nodes`, `discovery_file`, `scope` | Fetch one rule by ID |
| `create_alert` | `yaml_file`, `pools`, `nodes`, `discovery_file`, `sh_targets`, `repos`, `owner_id`, `owner`, `update_if_exists` | Create from YAML (order-polled on Fleet); `sh_targets` disambiguates a multi-SH Distributed Guardsix cluster and is enforced (required if ambiguous) |
| `update_alert` | `alert_id`, `yaml_file`, `pools`, `nodes`, `discovery_file`, `sh_targets`, `repos`, `owner_id`, `owner` | Update an existing rule; `sh_targets` enforced the same way as `create_alert` |
| `activate` | `alert_ids: List[str]`, target flags | Enable rules |
| `deactivate` | `alert_ids: List[str]`, target flags | Disable rules |
| `delete` | `alert_ids: List[str]`, target flags | Delete rules |
| `resolve_targets` | `pools`, `nodes`, `discovery_file`, `sh_targets`, `enforce_sh_disambiguation` | Enumerate `Target` objects without operating; `enforce_sh_disambiguation` (default `False`) makes an unresolved multi-SH cluster fatal — only `create_alert`/`update_alert` pass `True`, so every other caller fans out across all Search Heads in the cluster instead of raising |
| `supports(capability)` | `"discover"`, `"backup"`, `"mitre"`, `"multi_target"` | Check backend capability |
| `capabilities` (property) | — | `frozenset` of supported capability strings |
| `close` | — | Release underlying HTTP sessions |

### Target and TargetResult

**`Target`** — a frozen dataclass identifying one destination:

| Field | Type | Description |
|---|---|---|
| `pool_uuid` | `Optional[str]` | Pool UUID (Fleet); `None` for standalone |
| `node_id` | `Optional[str]` | Node identifier (Fleet); `None` for standalone |
| `pool_name` | `Optional[str]` | Human-readable pool name (Fleet) |
| `node_name` | `Optional[str]` | Human-readable node name (Fleet) |
| `label` | `str` | Display string (e.g. `"SOC-Pool/lp-001"` or `"default"`) |
| `search_head_names` | `frozenset` | Every Search Head hostname in this target's pool (Fleet; empty for standalone) — lets a mutating verb exclude those repos without a second discovery lookup |
| `is_standalone` | property | `True` when `pool_uuid` is `None` |
| `display_label` | property | `"pool/Name (id)"` when the node name is known, else falls back to `label` |

**`TargetResult`** — outcome for one target:

| Field | Type | Description |
|---|---|---|
| `target` | `Target` | The target this result is for |
| `ok` | `bool` | `True` if the operation succeeded |
| `value` | `Any` | Response payload on success (list/dict/etc.) |
| `error` | `Optional[str]` | Error message string on failure |
| `exc_type` | `Optional[str]` | Exception class name on failure (e.g. `"APIError"`) |
| `status_code` | `Optional[int]` | HTTP status code on failure, when available |

**Fleet capabilities:**
```python
client.supports("multi_target")  # True — fan-out across pools
client.supports("backup")        # True — pre-delete snapshots
client.supports("mitre")         # True — ATT&CK tag resolution
client.supports("discover")      # True — discovery.json generation
```
Standalone returns `False` for all of the above.

### Backend SDKs

For advanced or direct use, the underlying SDK classes are importable:

```python
from g6_alert.backends.standalone import AlertSDK
from g6_alert.backends.fleet import FleetAlertSDK
```

#### `AlertSDK` (standalone) key methods

| Method | Returns | Notes |
|---|---|---|
| `list_alerts(search, enabled, limit)` | `List[Dict]` | Auto-paginates internally; always returns the complete result set |
| `get_alert(alert_id)` | `Dict` | Raises `AlertNotFoundError` |
| `create_alert(yaml_path, yaml_data, check_exists, extra_metadata, repos, update_if_exists)` | `Dict` | |
| `update_alert(alert_id, yaml_path, yaml_data, repos, extra_metadata)` | `Dict` | |
| `activate_alert(alert_ids)` | `Dict` | |
| `deactivate_alert(alert_ids)` | `Dict` | |
| `delete_alert(alert_ids)` | `Dict` | |
| `fetch_repos(notify_search_head)` | `List[str]` | Excludes local loopback repos on Search Head |
| `install_pack(pack_id, packs_dir, check_exists, dry_run)` | `Dict` | |
| `close()` | `None` | |

#### `FleetAlertSDK` (fleet) key methods

All mutating methods require `pool_uuid` and `node_id`.

| Method | Returns | Notes |
|---|---|---|
| `list_alerts(pool_uuid, node_id, scope, search, status, risk, active, api_version)` | `List[Dict]` | Does not paginate — always returns the complete list in one call |
| `get_alert(pool_uuid, node_id, alert_id, scope)` | `Dict` | |
| `create_alert(pool_uuid, node_id, yaml_file, owner_id, ...)` | `Dict` | Async order polled automatically |
| `update_alert(pool_uuid, node_id, alert_id, yaml_file, ...)` | `Dict` | |
| `activate_alert(pool_uuid, node_id, alert_id)` | `Dict` | |
| `deactivate_alert(pool_uuid, node_id, alert_id)` | `Dict` | |
| `delete_alert(pool_uuid, node_id, alert_id)` | `Dict` | |
| `bulk_activate_alerts(pool_uuid, node_id, alert_keys)` | `List[Dict]` | Per-key results; a key can be `"indeterminate": True` if its outcome couldn't be confirmed |
| `bulk_deactivate_alerts(pool_uuid, node_id, alert_keys)` | `List[Dict]` | Same per-key/`"indeterminate"` contract as `bulk_activate_alerts` |
| `bulk_delete_alerts(pool_uuid, node_id, alert_keys)` | `List[Dict]` | Same per-key/`"indeterminate"` contract |
| `fetch_repos(pool_uuid, node_id, search_head_names)` | `List[str]` | |
| `resolve_mitre_attack_tags(pool_uuid, node_id, technique_ids)` | `List[str]` | Falls back to parent technique for sub-techniques |
| `refresh_token()` | `str` | Returns new access token; updates session header |
| `close()` | `None` | |

---

## YAML Rule Validation

- `validate_yaml_rule(rule_data)` in `g6_alert_core.rules` raises `ValueError` for
  missing required fields, invalid `risk` values, or wrong field types.
- YAML loading is path-traversal-safe: symlinks and non-`.yaml`/`.yml` extensions are
  rejected with `ValidationError` before the file is opened. Containment is anchored per
  operation, not to the current working directory — the rule file's own directory for
  `create`/`update`, the pack's content-root for `install-pack` — so rule files can live
  in a Rules repo released and checked out separately from this SDK/CLI.

### Rule YAML — `minimum_guardsix_version`

An optional top-level rule YAML field declaring the minimum Guardsix version the rule requires (e.g. `minimum_guardsix_version: 7.6.0`). **Fleet only** —
Standalone has no endpoint to fetch its own version, so the field is inert there (no
gating, no error). This supersedes the older `minimum_logpoint_version` metadata-only
field, which is unrelated and unchanged.

- **`create`/`update`** — blocks (raises) if the target node's actual version (from
  `MachineInfo`) doesn't meet the requirement, or can't be determined at all (fail
  closed). A rule with no `minimum_guardsix_version` never triggers the extra API call.
- **`install-pack`** — skips just the affected (rule, target) pair with a visible
  `skipped (requires Guardsix >= X (target is Y))` line; other rule/target pairs in the
  same run still proceed.
- **`pack-diff`** — never blocks (read-only): a failing pair is flagged as a `VERSION`
  entry in the diff output alongside whatever other category (MISSING/CHANGED/…) applies.
- **Version comparison** compares only the two versions' common prefix length — Guardsix
  now ships 4-component versions (e.g. `7.8.0.2`) where it used to ship 3
  (`7.6.0`), and rule YAML is still authored with 3 components, so a 4-part actual
  version is judged against a 3-part minimum using just its first 3 components.

### Rule YAML — `detection.timerange`

The alert search window, e.g. `timerange: 5 min`. The Alert API splits this into three
separate fields — `timerange_day`, `timerange_hour`, `timerange_minute` — of which
**exactly one may be non-zero**; the SDK converts the single YAML string into the correct
field on `create`/`update` for both Standalone and Fleet.

- **Format** — strictly `"<number> <unit>"` with a single space and `unit` one of `min`,
  `hr`, or `day` (case-insensitive). Plurals (`mins`, `hrs`), long forms (`minute`, `hour`,
  `hours`), bare numbers (`5`), and glued forms (`5min`) are all rejected.
- **Range** — `min`: 1–59, `hr`: 1–23, `day`: 1–30 (the server's own limits).
- **Default** — a rule YAML with no `timerange` field defaults to `5 min`.
- Invalid or out-of-range values raise an error before any API call (from `validate`,
  `create`, and `update`), rather than surfacing a raw server rejection.
- `get`/`backup` reconstruct `detection.timerange` from whichever of the three API fields
  is non-zero (e.g. `timerange_hour: 2` → `timerange: 2 hr`).

### Rule YAML — `notification.syslog`

Syslog notification configuration is Fleet-only at the moment. Standalone has no Syslog
Notification API, so a `notification.syslog` block in the rule YAML is silently skipped
there. After deploying rules to a Standalone SIEM, use the bulk syslog notification
update feature in the SIEM UI to apply the required syslog notification template to
the deployed rules.

On Fleet, the SDK builds the syslog message body for you. Your own `field_mapping` entries
(the event columns you want in the message, e.g. `incident_ts: log_ts`) are rendered as the
middle of the message. Around them, the SDK adds a fixed set of incident, rule, and
threat-context fields automatically, so you don't need to list these in `field_mapping`
yourself.

The incident and rule fields added before your mapped columns are `incident_name`,
`incident_risk_level`, `incident_log_source`, `alert_rule_author`, `alert_rule_reference`,
`incident_object_id`, and `incident_id`. The threat and MITRE ATT&CK fields added after your
mapped columns are `associated_malware`, `threat_actor`, `attack_category`, `attack_tag`,
`attack_id`, `attack_framework`, and `incident_criteria`. All of these are Fleet template
tokens filled in from the incident and rule when the alert fires, except `attack_framework`,
which is always emitted as the literal value `MITRE`.

This addition is unconditional: the fixed fields appear in the message even if
`field_mapping` is empty. If you also list one of these names in your own `field_mapping`,
it will appear twice in the message — once as your mapped entry and once as the field the
SDK adds automatically.

---

## discovery.json — Fleet Target Resolution

`discovery.json` is a Fleet-only file that maps pools and nodes to their UUIDs,
hostnames, IPs, and topology roles. It is the primary source for target resolution when
`--pool` is given without `--node`.

### Generate

```bash
g6-alert --profile prod discover -o discovery.json
```

Queries `monitorapi/v1/director/poolinfo` for pool/node enumeration, then probes each
node's `DistributedLogpoints` endpoint to assign roles, its
`SystemSettingsModesOfOperation`/`RawSyslogForwarder` endpoints to flag the (mutually
exclusive) Guardsix Collector / Raw Syslog Forwarder modes, and its `MachineInfo`
endpoint for the running Guardsix version (`guardsix_version`). File permissions:
`0o600`.

### Schema

The file is a **multi-profile cache** (`version: 2`), keyed by profile name.
Each profile section contains its own `fleet_url`, `last_updated` timestamp, and
`discovery` data. The discovery-cache `version: 2` is intentionally distinct from
the config file's `version: 1`.

```json
{
  "version": 2,
  "profiles": {
    "prod": {
      "fleet_url": "https://fleet.example.com",
      "last_updated": "2026-06-08T10:00:00",
      "discovery": {
        "pools": [
          {
            "pool_uuid": "p1",
            "pool_name": "SOC-Pool",
            "nodes": [
              {
                "node_identifier": "lp-001",
                "hostname": "node-sh-01",
                "ip": "10.0.0.10",
                "status": "active",
                "is_search_head": true,
                "is_data_node": false,
                "is_collector": false,
                "is_raw_syslog_forwarder": false,
                "guardsix_version": "7.8.0.2",
                "private_ip": null
              },
              {
                "node_identifier": "lp-002",
                "hostname": "node-dn-01",
                "ip": "10.0.0.20",
                "is_search_head": false,
                "is_data_node": true,
                "is_collector": false,
                "is_raw_syslog_forwarder": false,
                "guardsix_version": "7.8.0.2",
                "private_ip": "10.0.0.20"
              }
            ]
          }
        ]
      }
    }
  }
}
```

Multiple profiles (`prod`, `lab`, …) coexist in the same file; `g6-alert discover`
merges-updates only the active profile's section, leaving others untouched.

### Consumption

- The CLI resolves `--pool SOC-Pool` to all nodes in that pool.
- `install-pack` and rule operations skip nodes where `is_data_node`, `is_collector`, or
  `is_raw_syslog_forwarder` is `true` — none of these can host alert rules or run
  analytics.
- `guardsix_version` isn't itself used for target resolution — it's re-fetched live (via
  `MachineInfo`) at create/update/install-pack/pack-diff time for the
  `minimum_guardsix_version` rule-YAML gate (Fleet only; see
  [Rule YAML — `minimum_guardsix_version`](#rule-yaml--minimum_guardsix_version)).
- A warning is logged if a profile's `last_updated` is more than **7 days** old;
  a missing file (or missing profile section) prompts `g6-alert discover`.
- Default path: `~/.g6/discovery.json` (co-located with the config file);
  overridden by `--discovery-file` or `profile.discovery_file`. Any explicit absolute
  path is accepted (not restricted to CWD or `~/.g6`) — only a relative path containing
  a `..` segment is rejected.

---

## Distributed Guardsix Repo Resolution

How repo resolution behaves when a node is part of a Distributed Guardsix setup — a
Search Head with one or more Data Nodes attached. Read alongside
[`discovery.json`](#discoveryjson--fleet-target-resolution).

### Standalone

Standalone has no discovery file and no stored role model: it manages exactly one
Guardsix instance, so `is_search_head`/`is_data_node` flags don't exist anywhere.
Role detection is a runtime heuristic instead of a stored fact.

- **Search-Head detection.** `fetch_repos()` calls the SIEM's own
  `/Repo/get_all_searchable_logpoint`. More than one row in the response
  (`len(rows) > 1`) means the instance is acting as a Search Head for that call.
- **Local-repo exclusion.** When acting as a Search Head, any repo address
  starting with `127.0.0.1:5504/` (its own local copy) is dropped automatically,
  since a rule should never watch a Search Head's own local repo. Single-box
  installs with no Data Nodes (`len(rows) == 1`) keep local `127.0.0.1:5504/…`
  addresses as normal — exclusion only applies once the instance is genuinely
  acting as a Search Head.
- **Bare-name resolution auto-fans-out.** `repo: Windows` resolves against every
  fetched address ending in `/Windows`. If it exists on 2+ Data Nodes, all are
  included, never just one — no customer choice needed.
- **Local-address redirect.** A hardcoded `127.0.0.1:5504/Windows` (e.g. copied
  from another rule) is treated exactly like the bare name `Windows` and
  redirected to the real Data-Node address(es) when any exist.
- **Full remote address is a trusted override.** A full `host:port/name` address
  that isn't the local prefix passes through unchanged, with no live lookup.
- **No `--sh-target` equivalent, and none needed.** One instance means no "which
  Search Head should host this?" ambiguity; multi-Data-Node fan-out here means
  "include everything that matches" — the opposite of Fleet's ambiguity problem.

**Caveats:**
- Resolution fails closed rather than guessing: an unreachable server (with a repo
  explicitly requested) or a name matching nothing in the fetched list raises an
  error asking for the full `host:port/name` address.
- No repo configured means no built-in default. `create`/`update`, whether called
  directly or via `install-pack`'s create-per-rule path, raise `AlertSDKError` when
  a rule has no repo from `--repo` or its own `repo:` field — there is no hardcoded
  fallback list. `install-pack` alone keeps one further, lowest-priority tier: a
  rule that pins no repo anywhere falls back to every auto-discovered repo
  (Search-Head-local copies excluded) rather than failing outright; it fails only
  when nothing is discoverable either.
- The reported address is trusted verbatim, with no reachability check. If the
  SIEM reports a tunnel/private IP the SDK's host can't reach, resolution still
  "succeeds"; the write may fail downstream with no auto-correction.

### Fleet

Fleet keeps an explicit, persisted role model instead of a runtime heuristic.
`g6-alert discover` probes each node's `DistributedLogpoints` endpoint and writes
`is_search_head`/`is_data_node`/`cluster_id`/`search_head`(s) into `discovery.json`,
plus `is_collector`/`is_raw_syslog_forwarder` from the
`SystemSettingsModesOfOperation`/`RawSyslogForwarder` endpoints. Every other command
reads these instead of re-probing live, so **re-run `discover` after any topology
change** — resolution acts on whatever `discovery.json` last recorded, not current
server state.

- **Multi-Search-Head clusters are merged, not left ambiguous per pair.** Search
  Heads sharing one or more Data-Node peers are grouped into a single `cluster_id`
  via union-find.
- **Non-analytics nodes never host alert rules, on every command.** An explicit
  `--node <id>` naming a Data Node, Guardsix Collector, or Raw Syslog Forwarder
  fails loud immediately with the specific role in the error; a broad `--pool`
  fan-out excludes them silently but visibly (a `skip —` line). Guardsix Collector
  and Raw Syslog Forwarder are Distributed Guardsix components that forward to Data
  Nodes/Search Heads, so a given node may render as a cluster Data Node or as
  Standalone — only the mode is tracked, not that forwarding topology.
- **`--sh-target` is required only for the three commands that place a rule** —
  `create`, `update`, `install-pack`. A targeted pool with 2+ Search Heads of the
  same cluster and no `--sh-target` fails closed with the candidate list. Every
  other command (`list`, `get`, `activate`/`deactivate`, `delete`, `pack-diff`,
  `get-users`, `restore`) has no such requirement: they fan out across every Search
  Head in the cluster and act wherever the rule actually exists (e.g. `delete`
  reports "not present" on the Search Heads without it, deletes it on the one that
  has it).
- **Repo auto-fetch excludes every Search Head's own local repos** in the pool,
  the same principle as Standalone's local-repo exclusion, driven by
  `discovery.json` instead of a runtime heuristic. Auto-fetch resolves a named
  repo (bare name → full address); it's never used as a default when a rule has
  no repo at all.
- **Repo source priority**, highest wins: CLI `--repo`, then the pack manifest's
  own per-rule `repo:` (`install-pack` only), then the rule YAML file's own
  top-level `repo:` (`create`/`update` skip the manifest-entry layer, since there's
  no manifest for a single rule). No repo from any of these sources is an error,
  not a default: `create` raises `AlertSDKError` outright; `update` falls back one
  further step to the target alert's own currently deployed repos, so a routine
  update that doesn't touch `repo:` doesn't wipe it, before raising if even that is
  empty. Neither ever substitutes a hardcoded repo list or silently expands to
  every repo in the pool.
- **`pack-diff` only flags repo as CHANGED when the pack explicitly pins one**
  (manifest `repo:` or the rule YAML's own `repo:`) — a rule that never pins a
  repo is never flagged just because the auto-resolved repo set naturally drifts
  as Data Nodes come and go.

**Caveats:**
- `install-pack`'s own pre-resolution pass (before the create/update call) is
  non-strict as an optimization — an unmatched bare name there just passes through
  unchanged rather than raising. That's not the final word, though: the
  `create`/`update` SDK call underneath always re-validates via the same
  fail-closed resolver `create`/`update` use directly, so a genuinely unresolvable
  name still raises a clean client-side error (with the known-repos list),
  isolated to that one rule — it does not depend on how the server handles it.
- A stale `discovery.json` can misclassify a Data Node as a normal target, or make
  an already-resolvable cluster look ambiguous again — always re-run `discover`
  after adding or moving a Data Node or Search Head.

---

## Error Handling

All SDK errors are subclasses of `AlertSDKError`. Catching it covers every failure:

```python
from g6_alert_core import AlertSDKError

try:
    results = client.create_alert("rules/my_detection.yaml", pools=["SOC-Pool"])
except AlertSDKError as e:
    print(f"SDK error: {e}")
```

For fine-grained handling, import the specific subclass:

```python
from g6_alert_core import (
    AlertSDKError,
    AuthenticationError,
    AlertNotFoundError,
    AlertAlreadyActiveError,
    ValidationError,
    APIError,
)

try:
    client.activate(["64abc123def456"], pools=["SOC-Pool"])
except AlertNotFoundError:
    print("Alert ID not found — check the ID or scope")
except AlertAlreadyActiveError:
    print("Already active — nothing to do")
except AuthenticationError as e:
    print(f"Token problem: {e}")
except APIError as e:
    print(f"HTTP {e.status_code}: {e.response_text}")
except AlertSDKError as e:
    print(f"Unexpected SDK error: {e}")
```

### Exception hierarchy

All inherit from `AlertSDKError(Exception)`:

| Exception | Raised when |
|---|---|
| `AuthenticationError` | Invalid credentials, expired/missing token, 401 after refresh attempt |
| `ConfigurationError` | Missing or invalid configuration (URL, profile type, etc.) |
| `ValidationError` | Bad YAML path (symlink, traversal, wrong extension), invalid pack manifest structure |
| `AlertNotFoundError` | `get_alert`, `activate`, or `deactivate` — rule ID not found. `delete` on a missing ID succeeds as a no-op instead of raising. |
| `AlertAlreadyActiveError` | Activating a rule that is already active (standalone) |
| `AlertAlreadyInactiveError` | Deactivating a rule that is already inactive (standalone) |
| `PoolNotFoundError` | Referenced pool not found (Fleet) |
| `NodeNotFoundError` | Referenced node not found (Fleet) |
| `PackManifestError` | Pack manifest missing, invalid schema, or security validation failure |
| `APIError` | Non-2xx HTTP response, request failure after retries, Fleet order timeout/failure |
| `AlertSDKError` | Base class; also raised directly for generic logic errors |

`APIError` exposes `.status_code`, `.response_text`, and `.response` for programmatic
inspection.

The CLI converts all `AlertSDKError` subclasses to a non-zero exit code and a
`ClickException` message printed to stderr.

---

## Architecture

`g6-alert` is built on a **Dependency Inversion** pattern: the `AlertClient` facade and
CLI speak only to the `Transport` and `Auth` abstract base classes defined in
`g6-alert-core`. Concrete backend implementations (`StandaloneTransport`,
`FleetTransport`) are selected once, at startup in `factory.py`, and injected into
the client. The unused backend's dependencies are loaded lazily, so only the active
backend's packages are imported.

```
g6_alert_core                (no HTTP, no CLI)
  Transport ABC / Auth ABC
  AlertClient facade
  Exceptions / Security / Rules / Packs / Formatting

g6-alert                     (depends on core)
  factory.py       build_client(profile) → AlertClient
  cli.py           g6-alert commands
  backends/
    standalone/    AlertSDK + StandaloneTransport + StandaloneAuth
    fleet/         FleetAlertSDK + FleetTransport + FleetAuth
```

The core package's own module summary is on its
[PyPI page](https://pypi.org/project/g6-alert-core/).

---

## Development & Testing

```bash
# Install with dev extras (pytest, pytest-cov)
pip install -e "./g6-alert[dev]"

# Run the test suite
cd g6-alert
pytest -q

# Run with branch coverage
pytest --cov --cov-report=term-missing

# Also run the core package tests
cd ../g6_alert_core
pip install -e ".[dev]"
pytest -q
```

Tests use an in-memory `FakeTransport` — no HTTP, no Guardsix instance required.
The `tests/helpers.py` module provides shared test doubles; `tests/conftest.py`
provides auto-discovered `cfg` and `patch_build` fixtures.

---

## Contributing

External contributions are closed for now. Both packages (`g6-alert` and
`g6-alert-core`) are distributed through PyPI only at this stage.

For working on the code from source, see [Development & Testing](#development--testing).

---

## License

Copyright 2026 Guardsix A/S.

Licensed under the Apache License, Version 2.0. You may not use this software except
in compliance with the License. See the LICENSE and NOTICE files for the full terms.
