From dec1203ad77f0c54b60094e3f82e8efceb9bfe9a Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Wed, 30 Sep 2026 23:52:41 +0000 Subject: [PATCH 1/4] Add independent mail dispositions, release and report remediation commands --- doc/cli/email-security.md | 34 +++++++ limacharlie/commands/mailsec.py | 83 ++++++++++++++++-- limacharlie/discovery.py | 1 + limacharlie/sdk/mailsec.py | 112 ++++++++++++++++++++---- tests/unit/test_cli_mailsec_revision.py | 19 ++++ tests/unit/test_sdk_mailsec.py | 48 +++++++++- 6 files changed, 271 insertions(+), 26 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index f6e55a07..ef0b390e 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -298,3 +298,37 @@ admin consent. Message trace also requires Microsoft's documented Exchange service-principal prerequisite; activity requires unified audit logging. Missing grants report `not_granted`. Pending, stale or error coverage must be checked before treating an empty list as evidence that no messages were blocked. + +## Disposition and release + +Disposition is an analyst decision independent of the engine verdict. Values are +`malicious`, `spam`, `graymail`, `benign`, and `simulation`. Notes support up to +1024 characters. Use `none` to filter messages awaiting a decision. + +```bash +limacharlie mailsec message disposition --disposition spam --note "Reviewed" +limacharlie mailsec message disposition --clear +limacharlie mailsec message list --disposition none +limacharlie mailsec message bulk-disposition --input-file ids.json --disposition simulation +limacharlie mailsec message release --reason "Confirmed safe" --mode analyst +``` + +Bulk disposition accepts 1–500 unique message IDs and reports individual errors. +A benign disposition repairs sender flagged history; malicious contributes once. +Neither changes the engine verdict nor triggers policy automations. +Release restores placement and records a benign verdict and disposition together. +It requires `mailsec.act`; alert-only organizations must explicitly add `--force`. +Retrying the same successful release does not add another verdict revision. + +Report resolution uses the same dispositions and needs `mailsec.set`. Optional +remediation additionally requires `mailsec.act` and uses preview then confirmation: + +```bash +limacharlie mailsec report resolve --disposition malicious --scope message --action quarantine_message +limacharlie mailsec report resolve --disposition malicious --scope message --action quarantine_message --confirm --reason "Confirmed threat" +``` + +Scope can be `message` or `campaign`. Preview keeps the report open, as do failed, +withheld, or partial remediation attempts. Successful resolution classifies the +linked original without changing its engine verdict. Report detail shows +`resolution_reply_status`; an ambiguous provider send is not automatically retried. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index ce6de08b..bef8e6c3 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -44,7 +44,7 @@ from ..cli import pass_context from ..client import Client from ..sdk.organization import Organization -from ..sdk.mailsec import BULK_ACTIONS, Mailsec, normalize_bulk_selection +from ..sdk.mailsec import BULK_ACTIONS, DISPOSITIONS, Mailsec, normalize_bulk_selection from ..output import format_output, detect_output_format from ..discovery import register_explain from ._input_helpers import load_file, load_stdin @@ -158,7 +158,7 @@ Revise a message's verdict as an analyst. Requires mailsec.act. This records a human triage decision over the scorer's — it is a -disposition, not a remediation — and appends to the message's verdict +verdict revision — and appends to the message's verdict history rather than overwriting it. --rationale is required and audited: at least one, at most ten, each <= 280 characters. @@ -179,7 +179,7 @@ The verdict revision history for one message, oldest first. Each entry is who decided (mode/actor), the verdict they set, when, and -the rationale they gave — the audit of how a message's disposition moved +the rationale they gave — the audit of how a message's verdict moved over time. --limit bounds the history returned. Check revisions_truncated before @@ -430,7 +430,7 @@ an outcome that already holds. Examples: - limacharlie mailsec report resolve --disposition true_positive + limacharlie mailsec report resolve --disposition malicious """ _EXPLAIN_REPORT_REOPEN = """\ @@ -1053,6 +1053,7 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None @click.option("--campaign-id", default=None, help="Only members of this campaign.") @click.option("--state", multiple=True, help="Message state (repeatable).") @click.option("--direction", multiple=True, help="inbound|outbound|internal (repeatable).") +@click.option("--disposition", default=None, type=click.Choice([*DISPOSITIONS, "none"]), help="Independent analyst/SOAR disposition; none selects untriaged.") @click.option("--lane", default=None, type=click.Choice(["live", "backfill"]), help="Processing lane. Cannot be combined with --mailbox, --sender-email or --campaign-id.") @click.option("--user-reported", is_flag=True, default=False, help="Only mail a person reported.") @@ -1068,7 +1069,7 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None @click.option("--limit", default=None, type=int, help="Page size.") @pass_context def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, state, - direction, lane, user_reported, no_user_reported, min_score, link_domain, + direction, lane, disposition, user_reported, no_user_reported, min_score, link_domain, attachment_sha256, q, since, until, cursor, limit) -> None: """The message index — the triage queue. @@ -1082,6 +1083,8 @@ def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id ms = _get_mailsec(ctx) try: lane_params = {"lane": lane} if lane is not None else {} + if disposition is not None: + lane_params["disposition"] = disposition result = ms.list_messages( verdict=list(verdict) or None, mailbox=mailbox, @@ -1559,17 +1562,31 @@ def report_get(ctx, report_id) -> None: @report_group.command("resolve") @click.argument("report_id") @click.option("--disposition", required=True, - type=click.Choice(["true_positive", "false_positive", "benign"]), + type=click.Choice(DISPOSITIONS), help="What was decided. 'unknown' is deliberately not offered.") +@click.option("--scope", type=click.Choice(["message", "campaign"]), default=None) +@click.option("--action", type=click.Choice(BULK_ACTIONS), default=None) +@click.option("--confirm", default=None, help="Token from remediation_preview; omit to preview without resolving.") +@click.option("--reason", default="") +@click.option("--force", is_flag=True, help=_FORCE_HELP) @pass_context -def report_resolve(ctx, report_id, disposition) -> None: +def report_resolve(ctx, report_id, disposition, scope, action, confirm, reason, force) -> None: """Close a report with a disposition (mailsec.set). \b Example: - limacharlie mailsec report resolve --disposition true_positive + limacharlie mailsec report resolve --disposition malicious """ - _output(ctx, _get_mailsec(ctx).resolve_report(report_id, disposition)) + if bool(scope) != bool(action): + raise click.UsageError("--scope and --action must be provided together") + if not scope and (confirm or reason or force): + raise click.UsageError("remediation flags require --scope and --action") + kwargs = {} + if scope: + kwargs["remediation"] = {"scope": scope, "action": action, "reason": reason, "force": force} + if confirm: + kwargs["remediation"]["confirm"] = confirm + _output(ctx, _get_mailsec(ctx).resolve_report(report_id, disposition, **kwargs)) @report_group.command("reopen") @@ -1825,3 +1842,51 @@ def release_request_list(ctx, connection, status, since, until, cursor, limit) - register_explain("mailsec.provider-quarantine.list", "Microsoft provider delivery observations. Inspect coverage before interpreting an empty list. Requires optional ExchangeMessageTrace.Read.All consent.") register_explain("mailsec.release-request.list", "Microsoft hosted-quarantine release activity. This command observes requests and never approves a release. Requires optional ActivityFeed.Read consent and unified audit logging.") + +@message_group.command("disposition") +@click.argument("msg_uuid") +@click.option("--disposition", type=click.Choice(DISPOSITIONS), default=None) +@click.option("--clear", is_flag=True, help="Remove the decision with attribution.") +@click.option("--note", default="", help="Optional decision note (max 1024 characters).") +@pass_context +def message_disposition(ctx, msg_uuid, disposition, clear, note) -> None: + """Set or clear disposition independently of the verdict (mailsec.set).""" + try: + _output(ctx, _get_mailsec(ctx).set_disposition(msg_uuid, disposition, clear=clear, note=note)) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc + + +@message_group.command("bulk-disposition") +@click.option("--msg-uuids", multiple=True, help="Stable message ids (repeatable or comma-separated).") +@click.option("--input", "input_file", default=None, type=click.Path(exists=True, dir_okay=False)) +@click.option("--disposition", type=click.Choice(DISPOSITIONS), default=None) +@click.option("--clear", is_flag=True) +@click.option("--note", default="") +@pass_context +def message_bulk_disposition(ctx, msg_uuids, input_file, disposition, clear, note) -> None: + """Set an independent disposition on at most 500 messages (mailsec.set).""" + try: + ids = _bulk_selection(msg_uuids, input_file) + result = _get_mailsec(ctx).set_bulk_disposition(ids, disposition, clear=clear, note=note) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc + _output(ctx, result) + if any(item.get("error") for item in result.get("results", [])): + ctx.exit(1) + + +@message_group.command("release") +@click.argument("msg_uuid") +@click.option("--reason", required=True) +@click.option("--mode", default="analyst", type=click.Choice(["analyst", "ai"])) +@click.option("--force", is_flag=True, help=_FORCE_HELP) +@pass_context +def message_release(ctx, msg_uuid, reason, mode, force) -> None: + """Restore and classify a message as benign (mailsec.act).""" + try: + result = _get_mailsec(ctx).release_message(msg_uuid, reason=reason, mode=mode, force=force) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc + _output(ctx, result) + _note_force_required(ctx, result, "Re-run with --force to release it.", force) diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index a8e29fb4..fb1f75c5 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -268,6 +268,7 @@ "mailsec message list", "mailsec message get", "mailsec message eml", "mailsec message similar", "mailsec message action", "mailsec message revise", "mailsec message revisions", + "mailsec message disposition", "mailsec message bulk-disposition", "mailsec message release", "mailsec message bulk-action", "mailsec message bulk-status", "mailsec campaign list", "mailsec campaign get", "mailsec campaign action", "mailsec sender get", diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 1e6281a7..07ef04fc 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -63,6 +63,8 @@ # the limit from a clear local error rather than from a 400 after the round # trip. These match the gateway's own validation: at least one line, at most # ten, each no longer than 280 characters. +DISPOSITIONS = ("malicious", "spam", "graymail", "benign", "simulation") + _MAX_RATIONALE_LINES = 10 _MAX_RATIONALE_LEN = 280 @@ -312,6 +314,7 @@ def list_messages( state: list[str] | None = None, direction: list[str] | None = None, lane: str | None = None, + disposition: str | None = None, user_reported: bool | None = None, min_score: int | None = None, link_domain: str | None = None, @@ -337,6 +340,7 @@ def list_messages( campaign_id: Only members of one campaign. state: Message lifecycle state (repeatable). direction: ``inbound``, ``outbound``, ``internal`` (repeatable). + disposition: Analyst/SOAR label or ``none`` for untriaged. lane: ``live`` or ``backfill``. Omit to include either processing lane. Supported with time, verdict and IOC queries; combining it with mailbox, sender_email or campaign_id is refused by @@ -364,6 +368,8 @@ def list_messages( ValueError: If non-empty ``q`` is too long or lacks a bounded-walk companion filter. """ + if disposition is not None and disposition not in (*DISPOSITIONS, "none"): + raise ValueError("invalid disposition filter") if q is not None: q = q.strip() or None if q: @@ -395,6 +401,7 @@ def list_messages( ("limit", limit), ("user_reported", user_reported), ("lane", lane), + ("disposition", disposition), ): _add_scalar(pairs, key, val) return self._get("messages", pairs) @@ -491,6 +498,66 @@ def list_similar_messages( ) return self._get(f"messages/{_seg(msg_uuid)}/similar") + def set_disposition(self, msg_uuid: str, disposition: str | None = None, *, note: str = "", clear: bool = False) -> dict[str, Any]: + """Set or clear an analyst/SOAR decision without changing the verdict. + + Args: + msg_uuid: Stable message identity. + disposition: Malicious, spam, graymail, benign, or simulation. + note: Optional decision note, at most 1024 characters. + clear: Remove the label while recording who cleared it. + + Returns: + dict: Applied flag, decision sequence, and attributed decision. + + Raises: + ValueError: If the value/note is invalid or clear conflicts with a value. + """ + body = _disposition_body(disposition, note, clear) + return self._post(f"messages/{_seg(msg_uuid)}/disposition", body) + + def set_bulk_disposition(self, msg_uuids: list[str], disposition: str | None = None, *, note: str = "", clear: bool = False) -> dict[str, Any]: + """Set one independent decision on a bounded selection of messages. + + Args: + msg_uuids: One to 500 unique stable message identities. + disposition: Malicious, spam, graymail, benign, or simulation. + note: Optional decision note, at most 1024 characters. + clear: Remove the labels while recording attribution. + + Returns: + dict: Per-message results, with partial failures reported explicitly. + + Raises: + ValueError: If the selection or decision is invalid. + """ + body = _disposition_body(disposition, note, clear) + if not 1 <= len(msg_uuids) <= 500 or len(set(msg_uuids)) != len(msg_uuids): + raise ValueError("msg_uuids must contain 1-500 unique message ids") + if any(not isinstance(i, str) or not i.strip() or len(i) > 36 for i in msg_uuids): + raise ValueError("invalid message id") + body["msg_uuids"] = msg_uuids + return self._post("messages/dispositions", body) + + def release_message(self, msg_uuid: str, *, reason: str, mode: str = "analyst", force: bool = False) -> dict[str, Any]: + """Restore a message, revise its verdict and set disposition to benign. + + Args: + msg_uuid: Stable message identity. + reason: Audited reason for releasing the message. + mode: Analyst or ai decision mode. + force: Override alert-only mode for this action. + + Returns: + dict: Audited release outcome; alert_only means no changes were made. + + Raises: + ValueError: If mode or reason is invalid. + """ + if mode not in ("analyst", "ai") or not reason.strip() or len(reason) > 1024: + raise ValueError("release requires a bounded reason and analyst/ai mode") + return self._post(f"messages/{_seg(msg_uuid)}/actions", {"action": "release_message", "reason": reason, "mode": mode, "force": force}) + def act_on_message( self, msg_uuid: str, @@ -1144,26 +1211,28 @@ def get_report(self, report_id: str) -> dict[str, Any]: """ return self._get(f"reports/{_seg(report_id)}") - def resolve_report(self, report_id: str, disposition: str) -> dict[str, Any]: - """Close a report with a disposition. Requires ``mailsec.set``. + def resolve_report(self, report_id: str, disposition: str, *, remediation: dict[str, Any] | None = None) -> dict[str, Any]: + """Resolve a report and classify its linked message independently of verdict. Args: - report_id: The report. - disposition: ``true_positive`` (it was malicious), - ``false_positive`` (we flagged it and it was fine), or - ``benign`` (it was never a threat). ``unknown`` is a real - stored value but is NOT resolvable by a human: as the outcome - of someone closing a report it means "I looked and decided - nothing", which is indistinguishable in the SLA numbers from - never having looked. + report_id: Report to resolve. + disposition: Malicious, spam, graymail, benign, or simulation. + remediation: Optional scope/action request. Without confirm, previews + the action and leaves the report open; pass the returned confirm + token to execute and resolve. Remediation requires mailsec.act. Returns: - The updated report plus ``already_resolved``. Resolving twice - succeeds and says so — two analysts clicking at once is ordinary, - and the second must not get a failure for an outcome that already - holds. + dict: Updated report, or remediation_preview without a resolution. + + Raises: + ValueError: If disposition is outside the closed vocabulary. """ - return self._post(f"reports/{_seg(report_id)}/resolve", {"disposition": disposition}) + if disposition not in DISPOSITIONS: + raise ValueError("invalid disposition") + body: dict[str, Any] = {"disposition": disposition} + if remediation is not None: + body["remediation"] = remediation + return self._post(f"reports/{_seg(report_id)}/resolve", body) def reopen_report(self, report_id: str) -> dict[str, Any]: """Reopen a resolved report. Requires ``mailsec.set``. @@ -1400,3 +1469,16 @@ def purge_tenant(self, confirmation: str, reason: str | None = None) -> dict[str ) pairs.append(("reason", reason)) return self._delete("tenant", pairs) + + +def _disposition_body(disposition: str | None, note: str, clear: bool) -> dict[str, Any]: + if (clear and disposition is not None) or (not clear and disposition not in DISPOSITIONS): + raise ValueError("provide a valid disposition or clear=True") + if len(note) > 1024: + raise ValueError("note must contain at most 1024 characters") + body: dict[str, Any] = {"note": note} + if clear: + body["clear"] = True + else: + body["disposition"] = disposition + return body diff --git a/tests/unit/test_cli_mailsec_revision.py b/tests/unit/test_cli_mailsec_revision.py index a0715b2c..c04e1087 100644 --- a/tests/unit/test_cli_mailsec_revision.py +++ b/tests/unit/test_cli_mailsec_revision.py @@ -121,3 +121,22 @@ def test_reopen_reaches_the_sdk(self): ) assert result.exit_code == 0, result.output mailsec.reopen_report.assert_called_once_with("rep-1") + +class TestDispositionCommands: + def test_set_clear_release_and_filter_reach_sdk(self): + result, sdk = _invoke("mailsec", "message", "disposition", "msg-1", "--disposition", "spam", "--note", "reviewed", sdk_returns={"set_disposition": {"applied": True}}) + assert result.exit_code == 0, result.output + sdk.set_disposition.assert_called_once_with("msg-1", "spam", clear=False, note="reviewed") + result, sdk = _invoke("mailsec", "message", "disposition", "msg-1", "--clear", sdk_returns={"set_disposition": {"applied": True}}) + assert result.exit_code == 0, result.output + sdk.set_disposition.assert_called_once_with("msg-1", None, clear=True, note="") + result, sdk = _invoke("mailsec", "message", "release", "msg-1", "--reason", "reviewed", "--mode", "ai", "--force", sdk_returns={"release_message": {"result": "ok"}}) + assert result.exit_code == 0, result.output + sdk.release_message.assert_called_once_with("msg-1", reason="reviewed", mode="ai", force=True) + + def test_report_remediation_preview_and_confirmation_forward(self): + result, sdk = _invoke("mailsec", "report", "resolve", "rep-1", "--disposition", "malicious", "--scope", "message", "--action", "quarantine_message", "--confirm", "token", "--force", sdk_returns={"resolve_report": {"report": {"status": "resolved"}}}) + assert result.exit_code == 0, result.output + sdk.resolve_report.assert_called_once_with("rep-1", "malicious", remediation={"scope": "message", "action": "quarantine_message", "confirm": "token", "reason": "", "force": True}) + result, _ = _invoke("mailsec", "report", "resolve", "rep-1", "--disposition", "malicious", "--scope", "message") + assert result.exit_code != 0 and "provided together" in result.output diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index de85872b..c3671072 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -387,10 +387,10 @@ def test_status_repeats(self, ms, mock_org): assert ("status", "triaging") in qp def test_resolve_sends_disposition(self, ms, mock_org): - ms.resolve_report("rep-1", "true_positive") + ms.resolve_report("rep-1", "malicious") url, body = _post_call(mock_org) assert url == f"mailsec/{OID}/reports/rep-1/resolve" - assert body == {"disposition": "true_positive"} + assert body == {"disposition": "malicious"} def test_reopen_sends_empty_body(self, ms, mock_org): ms.reopen_report("rep-1") @@ -858,3 +858,47 @@ def test_tenant_filters_and_opaque_cursor_reach_the_transport(self, ms, mock_org assert url == f"mailsec/{OID}/{path}" assert dict(pairs) == {"connection": "m365", "status": status, "since": "2026-09-01T00:00:00Z", "until": "2026-09-02T00:00:00Z", "cursor": "opaque+/=", "limit": "25"} assert result == response + +class TestDispositionFeedback: + @pytest.mark.parametrize("value", ["malicious", "spam", "graymail", "benign", "simulation"]) + def test_valid_values_forward_without_changing_verdict(self, ms, mock_org, value): + ms.set_disposition("msg-1", value, note="reviewed") + url, body = _post_call(mock_org) + assert url == f"mailsec/{OID}/messages/msg-1/disposition" + assert body == {"disposition": value, "note": "reviewed"} + ms.resolve_report("rep-1", value) + assert _post_call(mock_org)[1] == {"disposition": value} + + @pytest.mark.parametrize("value", ["true_positive", "false_positive", "unknown", "suspicious", ""]) + def test_obsolete_values_refuse_before_transport(self, ms, mock_org, value): + with pytest.raises(ValueError): + ms.set_disposition("msg-1", value) + with pytest.raises(ValueError): + ms.resolve_report("rep-1", value) + mock_org.client.request.assert_not_called() + + def test_clear_and_unicode_note_bounds(self, ms, mock_org): + ms.set_disposition("msg-1", clear=True, note="é" * 1024) + assert _post_call(mock_org)[1] == {"clear": True, "note": "é" * 1024} + mock_org.client.request.reset_mock() + for kwargs in [{"note": "é" * 1025}, {"clear": True}]: + with pytest.raises(ValueError): + ms.set_disposition("msg-1", "benign", **kwargs) + mock_org.client.request.assert_not_called() + + def test_bulk_refuses_oversized_and_duplicate_inputs(self, ms, mock_org): + for ids in [[], ["msg-1", "msg-1"], [str(i) for i in range(501)], [" "], ["x" * 37]]: + with pytest.raises(ValueError): + ms.set_bulk_disposition(ids, "spam") + mock_org.client.request.assert_not_called() + ms.set_bulk_disposition(["msg-1", "msg-2"], "spam") + assert _post_call(mock_org)[1]["msg_uuids"] == ["msg-1", "msg-2"] + + def test_filter_release_and_remediation_contract(self, ms, mock_org): + ms.list_messages(disposition="none") + assert ("disposition", "none") in _get_call(mock_org)[1] + ms.release_message("msg-1", reason="reviewed", mode="ai", force=True) + assert _post_call(mock_org)[1] == {"action": "release_message", "reason": "reviewed", "mode": "ai", "force": True} + remediation = {"scope": "message", "action": "quarantine_message"} + ms.resolve_report("rep-1", "malicious", remediation=remediation) + assert _post_call(mock_org)[1] == {"disposition": "malicious", "remediation": remediation} From 2e13ea7a75ace6193ddc6f68ae8d8fc178d97461 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 12:37:36 +0000 Subject: [PATCH 2/4] Support resumable group remediation during report resolution --- doc/cli/email-security.md | 10 +++++ limacharlie/commands/mailsec.py | 19 +++++++-- limacharlie/sdk/mailsec.py | 52 ++++++++++++++++++++++++- tests/unit/test_cli_mailsec_revision.py | 10 +++++ tests/unit/test_sdk_mailsec.py | 36 +++++++++++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index ef0b390e..259ccac1 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -332,3 +332,13 @@ Scope can be `message` or `campaign`. Preview keeps the report open, as do faile withheld, or partial remediation attempts. Successful resolution classifies the linked original without changing its engine verdict. Report detail shows `resolution_reply_status`; an ambiguous provider send is not automatically retried. + + +For report remediation across copies of the same message, use `--scope group` +with a UUID `--attempt` reused through preview, confirmation and polling. +Unconfirmed requests return `remediation_preview: {job, confirmation}`; confirmed +requests may return `remediation_pending: true`. Add `--wait` (maximum 300 +seconds) to poll for a ready preview or a resolved report. Timeout exits with +code 2, retaining the job; repeat with the same attempt and confirmation to +resume. A failed or withheld job needs a new attempt and fresh confirmation. +Only the reported original is classified; every selected copy is remediated. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index bef8e6c3..cfa26fff 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -1564,13 +1564,16 @@ def report_get(ctx, report_id) -> None: @click.option("--disposition", required=True, type=click.Choice(DISPOSITIONS), help="What was decided. 'unknown' is deliberately not offered.") -@click.option("--scope", type=click.Choice(["message", "campaign"]), default=None) +@click.option("--scope", type=click.Choice(["message", "group", "campaign"]), default=None) @click.option("--action", type=click.Choice(BULK_ACTIONS), default=None) @click.option("--confirm", default=None, help="Token from remediation_preview; omit to preview without resolving.") +@click.option("--attempt", default=None, help="Required UUID for group scope, reused for preview, confirmation and polling.") +@click.option("--wait", is_flag=True, help="Wait for the group preview or confirmed resolution; timeout leaves the durable job running.") +@click.option("--timeout", type=click.IntRange(1, 300), default=300, show_default=True) @click.option("--reason", default="") @click.option("--force", is_flag=True, help=_FORCE_HELP) @pass_context -def report_resolve(ctx, report_id, disposition, scope, action, confirm, reason, force) -> None: +def report_resolve(ctx, report_id, disposition, scope, action, confirm, attempt, wait, timeout, reason, force) -> None: """Close a report with a disposition (mailsec.set). \b @@ -1579,14 +1582,22 @@ def report_resolve(ctx, report_id, disposition, scope, action, confirm, reason, """ if bool(scope) != bool(action): raise click.UsageError("--scope and --action must be provided together") - if not scope and (confirm or reason or force): + if not scope and (confirm or reason or force or attempt): raise click.UsageError("remediation flags require --scope and --action") + if wait and scope != "group": + raise click.UsageError("--wait requires --scope group") kwargs = {} if scope: kwargs["remediation"] = {"scope": scope, "action": action, "reason": reason, "force": force} + if attempt: + kwargs["remediation"]["attempt"] = attempt if confirm: kwargs["remediation"]["confirm"] = confirm - _output(ctx, _get_mailsec(ctx).resolve_report(report_id, disposition, **kwargs)) + ms = _get_mailsec(ctx) + result = ms.wait_for_report_resolution(report_id, disposition, timeout=timeout, **kwargs) if wait else ms.resolve_report(report_id, disposition, **kwargs) + _output(ctx, result) + if wait and result.get("wait_timed_out"): + ctx.exit(2) @report_group.command("reopen") diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 07ef04fc..0cc6afba 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -52,6 +52,7 @@ import json import time import warnings +import uuid from typing import Any, Callable, TYPE_CHECKING from urllib.parse import quote as _quote @@ -1218,7 +1219,10 @@ def resolve_report(self, report_id: str, disposition: str, *, remediation: dict[ report_id: Report to resolve. disposition: Malicious, spam, graymail, benign, or simulation. remediation: Optional scope/action request. Without confirm, previews - the action and leaves the report open; pass the returned confirm + the action and leaves the report open. Group scope requires a UUID + attempt reused through preview, confirmation and polling; its + job may return remediation_pending until completed. Only the + reported original is classified. Pass the returned confirm token to execute and resolve. Remediation requires mailsec.act. Returns: @@ -1231,9 +1235,53 @@ def resolve_report(self, report_id: str, disposition: str, *, remediation: dict[ raise ValueError("invalid disposition") body: dict[str, Any] = {"disposition": disposition} if remediation is not None: - body["remediation"] = remediation + if remediation.get("scope") == "group": + try: + uuid.UUID(remediation.get("attempt", "")) + except (ValueError, TypeError, AttributeError): + raise ValueError("group remediation requires a UUID attempt reused through preview, confirmation and polling") from None + body["remediation"] = dict(remediation) return self._post(f"reports/{_seg(report_id)}/resolve", body) + def wait_for_report_resolution(self, report_id: str, disposition: str, *, remediation: dict[str, Any], timeout: int = 300, poll_interval: int = 2) -> dict[str, Any]: + """Wait for a group preview or confirmed report resolution. + + Args: + report_id: Report identifier. + disposition: The same disposition used for the preview. + remediation: Group action, UUID attempt and unchanged parameters; + include confirm to execute and resolve, omit to prepare only. + timeout: Maximum polling window, 1 to 300 seconds. + poll_interval: Seconds between polls, 1 to 30. + + Returns: + dict: Preview with job and confirmation, or resolved report. + wait_timed_out means the durable job continues; repeat with the + same attempt and confirmation to resume. HTTP errors propagate. + + Raises: + ValueError: If bounds or group parameters are invalid. + """ + if remediation.get("scope") != "group" or not 1 <= timeout <= 300 or not 1 <= poll_interval <= 30: + raise ValueError("requires group remediation and bounded timeout/poll interval") + frozen = dict(remediation) + deadline = time.monotonic() + timeout + for _ in range(302): + result = self.resolve_report(report_id, disposition, remediation=frozen) + if result.get("report", {}).get("status") == "resolved" or result.get("already_resolved") is True: + return result + preview = result.get("remediation_preview", {}) + if not frozen.get("confirm") and preview.get("job", {}).get("phase") == "ready" and preview.get("confirmation"): + return result + job = (result.get("remediation") or preview).get("job", {}) + if job.get("phase") not in ("preparing", "running"): + raise ValueError("incomplete group remediation outcome; report remains open") + remaining = deadline - time.monotonic() + if remaining <= 0: + return {**result, "wait_timed_out": True} + time.sleep(min(poll_interval, remaining)) + return {**result, "wait_timed_out": True} + def reopen_report(self, report_id: str) -> dict[str, Any]: """Reopen a resolved report. Requires ``mailsec.set``. diff --git a/tests/unit/test_cli_mailsec_revision.py b/tests/unit/test_cli_mailsec_revision.py index c04e1087..5a5e5c9f 100644 --- a/tests/unit/test_cli_mailsec_revision.py +++ b/tests/unit/test_cli_mailsec_revision.py @@ -140,3 +140,13 @@ def test_report_remediation_preview_and_confirmation_forward(self): sdk.resolve_report.assert_called_once_with("rep-1", "malicious", remediation={"scope": "message", "action": "quarantine_message", "confirm": "token", "reason": "", "force": True}) result, _ = _invoke("mailsec", "report", "resolve", "rep-1", "--disposition", "malicious", "--scope", "message") assert result.exit_code != 0 and "provided together" in result.output + +class TestReportGroupWait: + def test_wait_reuses_explicit_attempt(self): + result, sdk = _invoke("mailsec", "report", "resolve", "rep-1", "--disposition", "malicious", "--scope", "group", "--action", "quarantine_message", "--attempt", "11111111-1111-4111-8111-111111111111", "--confirm", "token", "--wait", sdk_returns={"wait_for_report_resolution": {"report": {"status": "resolved"}}}) + assert result.exit_code == 0, result.output + sdk.wait_for_report_resolution.assert_called_once_with("rep-1", "malicious", timeout=300, remediation={"scope": "group", "action": "quarantine_message", "reason": "", "force": False, "attempt": "11111111-1111-4111-8111-111111111111", "confirm": "token"}) + + def test_wait_timeout_is_nonzero(self): + result, _ = _invoke("mailsec", "report", "resolve", "rep-1", "--disposition", "malicious", "--scope", "group", "--action", "quarantine_message", "--attempt", "11111111-1111-4111-8111-111111111111", "--wait", sdk_returns={"wait_for_report_resolution": {"report": {"status": "open"}, "wait_timed_out": True}}) + assert result.exit_code == 2, result.output diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index c3671072..e5b9e811 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -902,3 +902,39 @@ def test_filter_release_and_remediation_contract(self, ms, mock_org): remediation = {"scope": "message", "action": "quarantine_message"} ms.resolve_report("rep-1", "malicious", remediation=remediation) assert _post_call(mock_org)[1] == {"disposition": "malicious", "remediation": remediation} + +class TestGroupReportResolution: + request = {"scope": "group", "action": "quarantine_message", "attempt": "11111111-1111-4111-8111-111111111111"} + + def test_group_attempt_is_required(self, ms, mock_org): + with pytest.raises(ValueError, match="UUID attempt"): + ms.resolve_report("r", "malicious", remediation={"scope": "group"}) + mock_org.client.request.assert_not_called() + + def test_wait_preserves_frozen_preview_and_confirmation(self, ms, mock_org): + ready = {"report": {"status": "open"}, "remediation_preview": {"job": {"phase": "ready"}, "confirmation": "token"}} + preparing = {"report": {"status": "open"}, "remediation_preview": {"job": {"phase": "preparing"}}} + mock_org.client.request.side_effect = [preparing, ready] + with patch("limacharlie.sdk.mailsec.time.sleep"): + assert ms.wait_for_report_resolution("r", "malicious", remediation=self.request) == ready + bodies = [json.loads(call.kwargs["raw_body"]) for call in mock_org.client.request.call_args_list] + assert bodies[0] == bodies[1] == {"disposition": "malicious", "remediation": self.request} + mock_org.client.request.reset_mock() + running = {"report": {"status": "open"}, "remediation_pending": True, "remediation": {"job": {"phase": "running"}}} + resolved = {"report": {"status": "resolved"}} + mock_org.client.request.side_effect = [running, resolved] + with patch("limacharlie.sdk.mailsec.time.sleep"): + assert ms.wait_for_report_resolution("r", "malicious", remediation={**self.request, "confirm": "token"}) == resolved + bodies = [json.loads(call.kwargs["raw_body"]) for call in mock_org.client.request.call_args_list] + assert bodies[0] == bodies[1] + assert bodies[0]["remediation"]["confirm"] == "token" + + def test_timeout_returns_job_and_unknown_is_refused(self, ms, mock_org): + pending = {"report": {"status": "open"}, "remediation_pending": True, "remediation": {"job": {"phase": "running", "job_id": "job"}}} + mock_org.client.request.return_value = pending + with patch("limacharlie.sdk.mailsec.time.monotonic", side_effect=[0, 2]): + result = ms.wait_for_report_resolution("r", "malicious", remediation={**self.request, "confirm": "token"}, timeout=1) + assert result["wait_timed_out"] is True and result["remediation"]["job"]["job_id"] == "job" + mock_org.client.request.return_value = {"report": {"status": "open"}} + with pytest.raises(ValueError, match="incomplete"): + ms.wait_for_report_resolution("r", "malicious", remediation=self.request) From 3df890cb60df1abc47cc72273a20bb41b3dd643f Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 12:56:15 +0000 Subject: [PATCH 3/4] Recover group confirmation when resuming an existing report action --- limacharlie/sdk/mailsec.py | 2 +- tests/unit/test_sdk_mailsec.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 0cc6afba..86bdf912 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1271,7 +1271,7 @@ def wait_for_report_resolution(self, report_id: str, disposition: str, *, remedi if result.get("report", {}).get("status") == "resolved" or result.get("already_resolved") is True: return result preview = result.get("remediation_preview", {}) - if not frozen.get("confirm") and preview.get("job", {}).get("phase") == "ready" and preview.get("confirmation"): + if not frozen.get("confirm") and preview.get("job", {}).get("phase") in ("ready", "running", "done") and preview.get("confirmation"): return result job = (result.get("remediation") or preview).get("job", {}) if job.get("phase") not in ("preparing", "running"): diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index e5b9e811..7c1162f8 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -906,6 +906,13 @@ def test_filter_release_and_remediation_contract(self, ms, mock_org): class TestGroupReportResolution: request = {"scope": "group", "action": "quarantine_message", "attempt": "11111111-1111-4111-8111-111111111111"} + @pytest.mark.parametrize("phase", ["running", "done"]) + def test_repeated_preview_recovers_confirmation(self, phase, ms, mock_org): + preview = {"report": {"status": "open"}, "remediation_preview": {"job": {"phase": phase}, "confirmation": "token"}} + mock_org.client.request.return_value = preview + assert ms.wait_for_report_resolution("r", "malicious", remediation=self.request) == preview + mock_org.client.request.assert_called_once() + def test_group_attempt_is_required(self, ms, mock_org): with pytest.raises(ValueError, match="UUID attempt"): ms.resolve_report("r", "malicious", remediation={"scope": "group"}) From 6932f8db012aef0d3c4435127c4b5cc2ba0eb5a7 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 23:22:17 +0000 Subject: [PATCH 4/4] Document null disposition and bulk retry outcomes Co-Authored-By: Claude Opus 5.5 (1M context) --- doc/cli/email-security.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index 259ccac1..f32eb4eb 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -303,7 +303,8 @@ before treating an empty list as evidence that no messages were blocked. Disposition is an analyst decision independent of the engine verdict. Values are `malicious`, `spam`, `graymail`, `benign`, and `simulation`. Notes support up to -1024 characters. Use `none` to filter messages awaiting a decision. +1024 characters. Use `none` to filter messages awaiting a decision. Message list and +detail return `disposition: null` when no decision is set or it was cleared. ```bash limacharlie mailsec message disposition --disposition spam --note "Reviewed" @@ -313,7 +314,10 @@ limacharlie mailsec message bulk-disposition --input-file ids.json --disposition limacharlie mailsec message release --reason "Confirmed safe" --mode analyst ``` -Bulk disposition accepts 1–500 unique message IDs and reports individual errors. +Bulk disposition accepts 1–500 unique message IDs and returns one ordered outcome per ID. +A missing message reports its own error without affecting the others. An error starting +with `not confirmed, retry the same decision` means the outcome is unknown; repeating the +same request is safe because identical decisions are no-ops. A benign disposition repairs sender flagged history; malicious contributes once. Neither changes the engine verdict nor triggers policy automations. Release restores placement and records a benign verdict and disposition together.