diff --git a/limacharlie/cli.py b/limacharlie/cli.py index 2f3660d..8af19cf 100644 --- a/limacharlie/cli.py +++ b/limacharlie/cli.py @@ -113,6 +113,7 @@ def _config_no_warnings() -> bool: "event": ("event", "group"), "exfil": ("exfil", "group"), "extension": ("extension", "group"), + "feedback": ("feedback", "group"), "external-adapter": ("adapter", "group"), "fp": ("fp", "group"), "group": ("group", "group"), diff --git a/limacharlie/commands/feedback.py b/limacharlie/commands/feedback.py new file mode 100644 index 0000000..dab4b05 --- /dev/null +++ b/limacharlie/commands/feedback.py @@ -0,0 +1,498 @@ +"""Feedback commands for LimaCharlie CLI v2. + +Commands for sending interactive feedback requests (approval, +acknowledgement, free-form question) to external channels and managing +feedback channel configuration via the ext-feedback extension. +""" + +from __future__ import annotations + +import json +from typing import Any + +import click + +from ..cli import pass_context +from ..client import Client +from ..sdk.organization import Organization +from ..sdk.feedback import Feedback +from ..output import format_output, detect_output_format +from ..discovery import register_explain + + +# --------------------------------------------------------------------------- +# Explain texts +# --------------------------------------------------------------------------- + +_EXPLAIN_REQUEST_APPROVAL = """\ +Send a simple Approve/Deny feedback request to a channel. + +When the recipient responds, the result is dispatched to the +specified destination: either added as a note to a case or used +to trigger a playbook. + +Channels must be configured first (see 'feedback channel' commands). +Channel types: web (built-in UI), slack, email, telegram, ms_teams. + +For web channels, the response includes a shareable URL. + +Optionally attach JSON data that will be included in the response +payload when the recipient approves or denies: + --approved-content '{"action": "isolate"}' + --denied-content '{"action": "skip"}' + +Timeout: use --timeout to auto-respond after N seconds if no human +responds. Requires --timeout-choice (approved or denied). + --timeout 300 --timeout-choice denied + --timeout 300 --timeout-choice denied --timeout-content '{"reason": "timeout"}' + +Examples: + limacharlie feedback request-approval \\ + --channel ops-slack --question "Isolate host-01?" \\ + --destination case --case-id 42 + limacharlie feedback request-approval \\ + --channel web-default --question "Approve remediation?" \\ + --destination playbook --playbook remediate-host + limacharlie feedback request-approval \\ + --channel ops-slack --question "Block IP 10.0.0.1?" \\ + --destination playbook --playbook block-ip \\ + --approved-content '{"ip": "10.0.0.1"}' + limacharlie feedback request-approval \\ + --channel ops-slack --question "Isolate host?" \\ + --destination case --case-id 42 \\ + --timeout 300 --timeout-choice denied +""" + +_EXPLAIN_REQUEST_ACK = """\ +Send an acknowledgement request (single Acknowledge button) to a +channel. + +When acknowledged, the result is dispatched to the specified +destination (case note or playbook trigger). + +Optionally attach JSON data included in the response payload: + --acknowledged-content '{"status": "seen"}' + +Timeout: use --timeout to auto-acknowledge after N seconds if no +human responds. + --timeout 300 + --timeout 300 --timeout-content '{"status": "auto-ack", "reason": "timeout"}' + +Examples: + limacharlie feedback request-ack \\ + --channel ops-slack --question "Alert: lateral movement detected" \\ + --destination case --case-id 42 + limacharlie feedback request-ack \\ + --channel email-oncall --question "Acknowledge incident #7" \\ + --destination playbook --playbook ack-handler + limacharlie feedback request-ack \\ + --channel ops-slack --question "Ack alert X" \\ + --destination case --case-id 42 --timeout 600 +""" + +_EXPLAIN_REQUEST_QUESTION = """\ +Send a question with a free-form text input field to a channel. + +The respondent types a text answer which is dispatched to the +specified destination (case note or playbook trigger). + +Timeout: use --timeout to auto-answer after N seconds if no human +responds. --timeout-content is required for question type (provides +the automatic answer). + --timeout 300 --timeout-content '{"answer": "no response", "reason": "timeout"}' + +Examples: + limacharlie feedback request-question \\ + --channel ops-slack --question "What is the root cause?" \\ + --destination case --case-id 42 + limacharlie feedback request-question \\ + --channel web-default \\ + --question "Provide remediation steps for host-01" \\ + --destination playbook --playbook collect-input + limacharlie feedback request-question \\ + --channel ops-slack --question "Root cause?" \\ + --destination case --case-id 42 \\ + --timeout 300 --timeout-content '{"answer": "no response"}' +""" + +_EXPLAIN_CHANNEL_LIST = """\ +List all configured feedback channels for the organization. + +Channels define where feedback requests are sent. Each channel has +a name, type (web, slack, email, telegram, ms_teams), and an optional +Tailored Output name that holds the channel credentials. + +The web channel type is built-in and does not require a Tailored +Output. + +Example: + limacharlie feedback channel list +""" + +_EXPLAIN_CHANNEL_ADD = """\ +Add a feedback channel to the organization's ext-feedback config. + +Channel types and their Tailored Output requirements: + web - No output needed (built-in web UI) + slack - Output with slack_api_token, slack_channel + email - Output with dest_host, dest_email, and optional SMTP creds + telegram - Output with bot_token, chat_id + ms_teams - Output with webhook_url + +The --output-name is required for all channel types except web. + +Examples: + limacharlie feedback channel add \\ + --name web-default --type web + limacharlie feedback channel add \\ + --name ops-slack --type slack --output-name slack-soc + limacharlie feedback channel add \\ + --name email-oncall --type email --output-name smtp-oncall + limacharlie feedback channel add \\ + --name tg-alerts --type telegram --output-name telegram-bot +""" + +_EXPLAIN_CHANNEL_REMOVE = """\ +Remove a feedback channel from the organization's ext-feedback config. + +This does not delete the associated Tailored Output. + +Example: + limacharlie feedback channel remove --name ops-slack +""" + +register_explain("feedback.request-approval", _EXPLAIN_REQUEST_APPROVAL) +register_explain("feedback.request-ack", _EXPLAIN_REQUEST_ACK) +register_explain("feedback.request-question", _EXPLAIN_REQUEST_QUESTION) +register_explain("feedback.channel.list", _EXPLAIN_CHANNEL_LIST) +register_explain("feedback.channel.add", _EXPLAIN_CHANNEL_ADD) +register_explain("feedback.channel.remove", _EXPLAIN_CHANNEL_REMOVE) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _output(ctx: click.Context, data: Any) -> None: + fmt = ctx.obj.output_format or detect_output_format() + if not ctx.obj.quiet: + click.echo(format_output(data, fmt)) + + +def _get_feedback(ctx: click.Context) -> Feedback: + client = Client(oid=ctx.obj.oid, environment=ctx.obj.environment, print_debug_fn=ctx.obj.debug_fn, debug_full_response=ctx.obj.debug_full, debug_curl=ctx.obj.debug_curl, debug_verbose=ctx.obj.debug_verbose) + org = Organization(client) + return Feedback(org) + + +# --------------------------------------------------------------------------- +# Shared option values +# --------------------------------------------------------------------------- + +_DESTINATION_CHOICES = click.Choice(["case", "playbook"], case_sensitive=False) +_CHANNEL_TYPE_CHOICES = click.Choice( + ["web", "slack", "email", "telegram", "ms_teams"], + case_sensitive=False, +) +_TIMEOUT_CHOICE_CHOICES = click.Choice(["approved", "denied"], case_sensitive=False) + + +# --------------------------------------------------------------------------- +# Group +# --------------------------------------------------------------------------- + +@click.group("feedback") +def group() -> None: + """Manage interactive feedback requests (ext-feedback). + + Send approval prompts, acknowledgement requests, or free-form + questions to external channels (Slack, Email, Telegram, Teams, + or built-in Web UI). Responses are dispatched to a case or + playbook. + + Use 'feedback channel' subcommands to configure channels before + sending requests. + """ + + +# --------------------------------------------------------------------------- +# request-approval +# --------------------------------------------------------------------------- + +@group.command("request-approval") +@click.option("--channel", required=True, help="Name of the configured feedback channel.") +@click.option("--question", required=True, help="Prompt text shown to the respondent.") +@click.option("--destination", required=True, type=_DESTINATION_CHOICES, + help="Where to send the response: 'case' (add note) or 'playbook' (trigger).") +@click.option("--case-id", default=None, help="Case number to attach the response to. Required when --destination=case.") +@click.option("--playbook", "playbook_name", default=None, + help="Playbook name to trigger with the response. Required when --destination=playbook.") +@click.option("--approved-content", default=None, + help="JSON object string included in the response payload when approved, e.g. '{\"action\": \"isolate\"}'.") +@click.option("--denied-content", default=None, + help="JSON object string included in the response payload when denied, e.g. '{\"action\": \"skip\"}'.") +@click.option("--timeout", "timeout_seconds", default=None, type=int, + help="Auto-respond after this many seconds with no human response (minimum 60). Requires --timeout-choice.") +@click.option("--timeout-choice", default=None, type=_TIMEOUT_CHOICE_CHOICES, + help="Which choice to auto-select on timeout: 'approved' or 'denied'. Required when --timeout is set.") +@click.option("--timeout-content", default=None, + help="JSON object string for the timeout response payload (overrides --approved-content/--denied-content for the timeout).") +@pass_context +def request_approval(ctx, channel, question, destination, case_id, + playbook_name, approved_content, denied_content, + timeout_seconds, timeout_choice, timeout_content) -> None: + """Send an Approve/Deny feedback request to a channel. + + The respondent sees Approve and Deny buttons. Their choice is + dispatched to the specified destination (case note or playbook). + + \b + Dependencies: + --destination case => --case-id is required + --destination playbook => --playbook is required + --timeout => --timeout-choice is required + + \b + Examples: + limacharlie feedback request-approval --channel web --question "Isolate host-01?" --destination case --case-id 42 + limacharlie feedback request-approval --channel ops-slack --question "Block IP?" --destination playbook --playbook block-ip --approved-content '{"ip":"10.0.0.1"}' + limacharlie feedback request-approval --channel web --question "Approve?" --destination case --case-id 7 --timeout 300 --timeout-choice denied + """ + approved = None + if approved_content is not None: + try: + approved = json.loads(approved_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--approved-content", + ) + denied = None + if denied_content is not None: + try: + denied = json.loads(denied_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--denied-content", + ) + tc = None + if timeout_content is not None: + try: + tc = json.loads(timeout_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--timeout-content", + ) + fb = _get_feedback(ctx) + data = fb.request_simple_approval( + channel, question, destination, + case_id=case_id, + playbook_name=playbook_name, + approved_content=approved, + denied_content=denied, + timeout_seconds=timeout_seconds, + timeout_choice=timeout_choice, + timeout_content=tc, + ) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# request-ack +# --------------------------------------------------------------------------- + +@group.command("request-ack") +@click.option("--channel", required=True, help="Name of the configured feedback channel.") +@click.option("--question", required=True, help="Prompt text shown to the respondent.") +@click.option("--destination", required=True, type=_DESTINATION_CHOICES, + help="Where to send the response: 'case' (add note) or 'playbook' (trigger).") +@click.option("--case-id", default=None, help="Case number to attach the response to. Required when --destination=case.") +@click.option("--playbook", "playbook_name", default=None, + help="Playbook name to trigger with the response. Required when --destination=playbook.") +@click.option("--acknowledged-content", default=None, + help="JSON object string included in the response payload when acknowledged, e.g. '{\"status\": \"seen\"}'.") +@click.option("--timeout", "timeout_seconds", default=None, type=int, + help="Auto-acknowledge after this many seconds with no human response (minimum 60).") +@click.option("--timeout-content", default=None, + help="JSON object string for the timeout response payload (overrides --acknowledged-content for the timeout).") +@pass_context +def request_ack(ctx, channel, question, destination, case_id, + playbook_name, acknowledged_content, + timeout_seconds, timeout_content) -> None: + """Send an acknowledgement request (single Acknowledge button). + + The respondent sees a single Acknowledge button. When clicked (or + on timeout), the response is dispatched to the specified destination. + + \b + Dependencies: + --destination case => --case-id is required + --destination playbook => --playbook is required + + \b + Examples: + limacharlie feedback request-ack --channel web --question "Alert: lateral movement on host-01" --destination case --case-id 42 + limacharlie feedback request-ack --channel ops-slack --question "Ack incident #7" --destination playbook --playbook ack-handler --timeout 600 + """ + ack_content = None + if acknowledged_content is not None: + try: + ack_content = json.loads(acknowledged_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--acknowledged-content", + ) + tc = None + if timeout_content is not None: + try: + tc = json.loads(timeout_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--timeout-content", + ) + fb = _get_feedback(ctx) + data = fb.request_acknowledgement( + channel, question, destination, + case_id=case_id, + playbook_name=playbook_name, + acknowledged_content=ack_content, + timeout_seconds=timeout_seconds, + timeout_content=tc, + ) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# request-question +# --------------------------------------------------------------------------- + +@group.command("request-question") +@click.option("--channel", required=True, help="Name of the configured feedback channel.") +@click.option("--question", required=True, help="Question text shown to the respondent (they reply with free-form text).") +@click.option("--destination", required=True, type=_DESTINATION_CHOICES, + help="Where to send the response: 'case' (add note) or 'playbook' (trigger).") +@click.option("--case-id", default=None, help="Case number to attach the response to. Required when --destination=case.") +@click.option("--playbook", "playbook_name", default=None, + help="Playbook name to trigger with the response. Required when --destination=playbook.") +@click.option("--timeout", "timeout_seconds", default=None, type=int, + help="Auto-answer after this many seconds with no human response (minimum 60). Requires --timeout-content.") +@click.option("--timeout-content", default=None, + help="JSON object string used as the automatic answer on timeout, e.g. '{\"answer\": \"no response\"}'. Required when --timeout is set.") +@pass_context +def request_question(ctx, channel, question, destination, case_id, + playbook_name, timeout_seconds, timeout_content) -> None: + """Send a question for free-form text response. + + The respondent sees a text input field. Their typed answer is + dispatched to the specified destination. + + \b + Dependencies: + --destination case => --case-id is required + --destination playbook => --playbook is required + --timeout => --timeout-content is required + + \b + Examples: + limacharlie feedback request-question --channel web --question "What is the root cause?" --destination case --case-id 42 + limacharlie feedback request-question --channel ops-slack --question "Remediation steps?" --destination playbook --playbook collect-input --timeout 300 --timeout-content '{"answer": "no response"}' + """ + tc = None + if timeout_content is not None: + try: + tc = json.loads(timeout_content) + except json.JSONDecodeError as exc: + raise click.BadParameter( + f"invalid JSON: {exc}", param_hint="--timeout-content", + ) + fb = _get_feedback(ctx) + data = fb.request_question( + channel, question, destination, + case_id=case_id, + playbook_name=playbook_name, + timeout_seconds=timeout_seconds, + timeout_content=tc, + ) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# channel subgroup +# --------------------------------------------------------------------------- + +@group.group("channel") +def channel_group() -> None: + """Manage feedback channels (configure before sending requests). + + Each channel has a name, a type, and (for non-web types) a + Tailored Output that holds credentials. + + \b + Channel types: + web Built-in web UI (no output needed, returns a shareable URL) + slack Sends Block Kit message (output needs: slack_api_token, slack_channel) + email Sends HTML email with link (output needs: dest_host, dest_email) + telegram Sends inline keyboard (output needs: bot_token, chat_id) + ms_teams Sends Adaptive Card (output needs: webhook_url) + """ + + +# --------------------------------------------------------------------------- +# channel list +# --------------------------------------------------------------------------- + +@channel_group.command("list") +@pass_context +def channel_list(ctx) -> None: + """List configured feedback channels. + + Returns a JSON array of {name, channel_type, output_name} objects. + """ + fb = _get_feedback(ctx) + data = fb.list_channels() + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# channel add +# --------------------------------------------------------------------------- + +@channel_group.command("add") +@click.option("--name", required=True, help="Unique channel name (used in --channel when sending requests).") +@click.option("--type", "channel_type", required=True, type=_CHANNEL_TYPE_CHOICES, + help="Channel type: web, slack, email, telegram, or ms_teams.") +@click.option("--output-name", default=None, + help="Tailored Output holding channel credentials. Required for all types except 'web'.") +@pass_context +def channel_add(ctx, name, channel_type, output_name) -> None: + """Add a feedback channel to the organization config. + + \b + Examples: + limacharlie feedback channel add --name web-default --type web + limacharlie feedback channel add --name ops-slack --type slack --output-name slack-soc + limacharlie feedback channel add --name tg-alerts --type telegram --output-name telegram-bot + """ + fb = _get_feedback(ctx) + data = fb.add_channel(name, channel_type, output_name=output_name) + if not ctx.obj.quiet: + click.echo(f"Channel '{name}' added.") + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# channel remove +# --------------------------------------------------------------------------- + +@channel_group.command("remove") +@click.option("--name", required=True, help="Channel name to remove.") +@pass_context +def channel_remove(ctx, name) -> None: + """Remove a feedback channel from the organization config. + + Does not delete the associated Tailored Output. + """ + fb = _get_feedback(ctx) + data = fb.remove_channel(name) + if not ctx.obj.quiet: + click.echo(f"Channel '{name}' removed.") + _output(ctx, data) diff --git a/limacharlie/sdk/feedback.py b/limacharlie/sdk/feedback.py new file mode 100644 index 0000000..143fd8b --- /dev/null +++ b/limacharlie/sdk/feedback.py @@ -0,0 +1,248 @@ +"""Feedback SDK for LimaCharlie v2. + +Wraps the ext-feedback extension for sending interactive feedback +requests (approval, acknowledgement, question) to external channels +(Slack, Email, Telegram, Teams, or built-in Web UI) and managing +channel configuration. +""" + +from __future__ import annotations + +import json +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from .organization import Organization + +from .extensions import Extensions +from .hive import Hive, HiveRecord + +_EXTENSION_NAME = "ext-feedback" + + +class Feedback: + """Feedback system client for LimaCharlie.""" + + def __init__(self, org: Organization) -> None: + self._org = org + + @property + def oid(self) -> str: + return self._org.oid + + # ------------------------------------------------------------------ + # Feedback requests + # ------------------------------------------------------------------ + + def request_simple_approval( + self, + channel: str, + question: str, + feedback_destination: str, + *, + case_id: str | None = None, + playbook_name: str | None = None, + approved_content: dict | None = None, + denied_content: dict | None = None, + timeout_seconds: int | None = None, + timeout_choice: str | None = None, + timeout_content: dict | None = None, + ) -> dict[str, Any]: + """Send a simple approval (Approve/Deny) request to a channel. + + Args: + channel: Name of the configured feedback channel. + question: The prompt to present to the respondent. + feedback_destination: "case" or "playbook". + case_id: Case number (required when destination is "case"). + playbook_name: Playbook to trigger (required when destination is "playbook"). + approved_content: JSON data included in the response when approved. + denied_content: JSON data included in the response when denied. + timeout_seconds: Auto-respond after this many seconds (minimum 60). + timeout_choice: Choice to auto-select on timeout ("approved" or "denied"); + required when timeout_seconds is set. + timeout_content: JSON data to include in the timeout response + (overrides the choice's content if set). + + Returns: + dict with request_id and optionally url (for web channels). + """ + data: dict[str, Any] = { + "channel": channel, + "question": question, + "feedback_destination": feedback_destination, + } + if case_id is not None: + data["case_id"] = case_id + if playbook_name is not None: + data["playbook_name"] = playbook_name + if approved_content is not None: + data["approved_content"] = json.dumps(approved_content) + if denied_content is not None: + data["denied_content"] = json.dumps(denied_content) + if timeout_seconds is not None: + data["timeout_seconds"] = timeout_seconds + if timeout_choice is not None: + data["timeout_choice"] = timeout_choice + if timeout_content is not None: + data["timeout_content"] = json.dumps(timeout_content) + ext = Extensions(self._org) + return ext.request(_EXTENSION_NAME, "request_simple_approval", data=data) + + def request_acknowledgement( + self, + channel: str, + question: str, + feedback_destination: str, + *, + case_id: str | None = None, + playbook_name: str | None = None, + acknowledged_content: dict | None = None, + timeout_seconds: int | None = None, + timeout_content: dict | None = None, + ) -> dict[str, Any]: + """Send an acknowledgement request to a channel. + + Args: + channel: Name of the configured feedback channel. + question: The prompt to present to the respondent. + feedback_destination: "case" or "playbook". + case_id: Case number (required when destination is "case"). + playbook_name: Playbook to trigger (required when destination is "playbook"). + acknowledged_content: JSON data included in the response when acknowledged. + timeout_seconds: Auto-acknowledge after this many seconds (minimum 60). + timeout_content: JSON data to include in the timeout response + (overrides acknowledged_content if set). + + Returns: + dict with request_id and optionally url (for web channels). + """ + data: dict[str, Any] = { + "channel": channel, + "question": question, + "feedback_destination": feedback_destination, + } + if case_id is not None: + data["case_id"] = case_id + if playbook_name is not None: + data["playbook_name"] = playbook_name + if acknowledged_content is not None: + data["acknowledged_content"] = json.dumps(acknowledged_content) + if timeout_seconds is not None: + data["timeout_seconds"] = timeout_seconds + if timeout_content is not None: + data["timeout_content"] = json.dumps(timeout_content) + ext = Extensions(self._org) + return ext.request(_EXTENSION_NAME, "request_acknowledgement", data=data) + + def request_question( + self, + channel: str, + question: str, + feedback_destination: str, + *, + case_id: str | None = None, + playbook_name: str | None = None, + timeout_seconds: int | None = None, + timeout_content: dict | None = None, + ) -> dict[str, Any]: + """Send a question with free-form text input to a channel. + + Args: + channel: Name of the configured feedback channel. + question: The question to present to the respondent. + feedback_destination: "case" or "playbook". + case_id: Case number (required when destination is "case"). + playbook_name: Playbook to trigger (required when destination is "playbook"). + timeout_seconds: Auto-answer after this many seconds (minimum 60). + timeout_content: JSON data to include in the timeout response + (required when timeout_seconds is set for question type). + + Returns: + dict with request_id and optionally url (for web channels). + """ + data: dict[str, Any] = { + "channel": channel, + "question": question, + "feedback_destination": feedback_destination, + } + if case_id is not None: + data["case_id"] = case_id + if playbook_name is not None: + data["playbook_name"] = playbook_name + if timeout_seconds is not None: + data["timeout_seconds"] = timeout_seconds + if timeout_content is not None: + data["timeout_content"] = json.dumps(timeout_content) + ext = Extensions(self._org) + return ext.request(_EXTENSION_NAME, "request_question", data=data) + + # ------------------------------------------------------------------ + # Channel configuration + # ------------------------------------------------------------------ + + def _get_config(self) -> HiveRecord: + hive = Hive(self._org, "extension_config") + return hive.get(_EXTENSION_NAME) + + def _set_config(self, record: HiveRecord) -> dict[str, Any]: + hive = Hive(self._org, "extension_config") + return hive.set(record) + + def list_channels(self) -> list[dict[str, Any]]: + """List configured feedback channels. + + Returns: + List of channel dicts with name, channel_type, and output_name. + """ + record = self._get_config() + return record.data.get("channels", []) + + def add_channel( + self, + name: str, + channel_type: str, + output_name: str | None = None, + ) -> dict[str, Any]: + """Add a feedback channel to the configuration. + + Args: + name: Unique channel name. + channel_type: One of web, slack, email, telegram, ms_teams. + output_name: Tailored Output name with channel credentials + (required for all types except web). + + Returns: + Hive set response. + """ + record = self._get_config() + channels = record.data.get("channels", []) + for ch in channels: + if ch.get("name") == name: + raise ValueError(f"channel {name!r} already exists") + entry: dict[str, str] = { + "name": name, + "channel_type": channel_type, + } + if output_name is not None: + entry["output_name"] = output_name + channels.append(entry) + record.data["channels"] = channels + return self._set_config(record) + + def remove_channel(self, name: str) -> dict[str, Any]: + """Remove a feedback channel from the configuration. + + Args: + name: Channel name to remove. + + Returns: + Hive set response. + """ + record = self._get_config() + channels = record.data.get("channels", []) + new_channels = [ch for ch in channels if ch.get("name") != name] + if len(new_channels) == len(channels): + raise ValueError(f"channel {name!r} not found") + record.data["channels"] = new_channels + return self._set_config(record) diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index d9ec3ef..9a6c780 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -47,7 +47,7 @@ EXPECTED_TOP_LEVEL_COMMANDS = frozenset({ "ai", "api", "api-key", "arl", "artifact", "audit", "auth", "billing", "case", "cloud-adapter", "completion", "config", "detection", "download", "dr", - "endpoint-policy", "event", "exfil", "extension", "external-adapter", + "endpoint-policy", "event", "exfil", "extension", "external-adapter", "feedback", "fp", "group", "help", "hive", "ingestion-key", "installation-key", "integrity", "ioc", "job", "logging", "lookup", "note", "org", "output", "payload", "playbook", "replay", "schema", "search", "secret", "sensor", @@ -78,6 +78,7 @@ "event": ("group", "event"), "exfil": ("group", "exfil"), "extension": ("group", "extension"), + "feedback": ("group", "feedback"), "fp": ("group", "fp"), "group": ("group", "group"), "help_cmd": ("group", "help"),