From 30f47a4ec584fcc433002f1944083270d643753d Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Fri, 3 Apr 2026 09:50:04 -0700 Subject: [PATCH 1/2] fix: cases CLI/SDK alignment with ext-cases backend - Fix export --with-data using wrong field name (detection_id -> detect_id), which caused all detection fetches to silently fail - Add is_public parameter to add_note SDK method and --is-public flag to CLI add-note command - Add update_note_visibility SDK method and CLI update-note command for toggling note public/private visibility - Add list_orgs SDK method and CLI orgs command for discovering ext-cases-subscribed organizations Co-Authored-By: Claude Opus 4.6 (1M context) --- limacharlie/commands/case_cmd.py | 77 +++++++++++++++++++++- limacharlie/sdk/cases.py | 42 +++++++++++- tests/unit/test_cli_case.py | 106 +++++++++++++++++++++++++++---- tests/unit/test_sdk_cases.py | 54 ++++++++++++++++ 4 files changed, 264 insertions(+), 15 deletions(-) diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index a24cd7e..cc2dd72 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -117,15 +117,30 @@ to_stakeholder - Notes to stakeholders from_stakeholder - Notes from stakeholders +Use --is-public to make the note visible to stakeholders (default: private). + Provide content via --content, --input-file, or stdin. Examples: limacharlie case add-note --case-number 42 --content "Initial triage complete" limacharlie case add-note --case-number 42 --type analysis \\ --content "Confirmed C2 beacon to 10.0.0.1" + limacharlie case add-note --case-number 42 --is-public \\ + --content "Status update for stakeholders" echo "Handoff notes" | limacharlie case add-note --case-number 42 --type handoff """ +_EXPLAIN_UPDATE_NOTE_VISIBILITY = """\ +Toggle a note's public/private visibility. Public notes are visible +to stakeholders. + +Requires the event ID of the note (from the case event timeline). + +Examples: + limacharlie case update-note --case-number 42 --event-id --is-public + limacharlie case update-note --case-number 42 --event-id --no-is-public +""" + _EXPLAIN_BULK_UPDATE = """\ Batch update up to 200 cases at once. Provide case numbers as a comma-separated list or via --input-file (one number per line or JSON @@ -371,6 +386,15 @@ limacharlie case assignees """ +_EXPLAIN_ORGS = """\ +List organizations subscribed to ext-cases that the current user +can access. Useful for multi-org users to discover which orgs +have cases enabled. + +Example: + limacharlie case orgs +""" + _EXPLAIN_EXPORT = """\ Export a case with all its components in a single JSON object. Fetches the case record (with event timeline), linked detections, @@ -434,6 +458,7 @@ register_explain("case.get", _EXPLAIN_GET) register_explain("case.update", _EXPLAIN_UPDATE) register_explain("case.add-note", _EXPLAIN_ADD_NOTE) +register_explain("case.update-note", _EXPLAIN_UPDATE_NOTE_VISIBILITY) register_explain("case.bulk-update", _EXPLAIN_BULK_UPDATE) register_explain("case.merge", _EXPLAIN_MERGE) register_explain("case.entity.list", _EXPLAIN_ENTITY_LIST) @@ -456,6 +481,7 @@ register_explain("case.config-get", _EXPLAIN_CONFIG_GET) register_explain("case.config-set", _EXPLAIN_CONFIG_SET) register_explain("case.assignees", _EXPLAIN_ASSIGNEES) +register_explain("case.orgs", _EXPLAIN_ORGS) register_explain("case.export", _EXPLAIN_EXPORT) _EXPLAIN_TAG_SET = """\ @@ -712,7 +738,7 @@ def _export_with_data(ctx: click.Context, t: Cases, data: dict[str, Any], det_dir = os.path.join(output_dir, "detections") os.makedirs(det_dir, exist_ok=True) for det in detections: - det_id = det.get("detection_id") + det_id = det.get("detect_id") if not det_id: continue try: @@ -834,8 +860,10 @@ def update(ctx, case_number, status, severity, assignees, classification, @click.option("--input-file", default=None, type=click.Path(exists=True), help="Read note content from file.") @click.option("--type", "note_type", default=None, type=_NOTE_TYPE_CHOICES, help="Note type (default: general).") +@click.option("--is-public/--no-is-public", default=None, + help="Make note visible to stakeholders (default: private).") @pass_context -def add_note(ctx, case_number, content, input_file, note_type) -> None: +def add_note(ctx, case_number, content, input_file, note_type, is_public) -> None: """Add a note to a case. Provide content via --content, --input-file, or stdin. @@ -844,6 +872,8 @@ def add_note(ctx, case_number, content, input_file, note_type) -> None: limacharlie case add-note --case-number 42 --content "Triage complete" limacharlie case add-note --case-number 42 --type analysis \\ --content "Confirmed C2 beacon" + limacharlie case add-note --case-number 42 --is-public \\ + --content "Status update for stakeholders" echo "notes" | limacharlie case add-note --case-number 42 """ if content: @@ -857,7 +887,29 @@ def add_note(ctx, case_number, content, input_file, note_type) -> None: raise click.UsageError("Provide content via --content, --input-file, or stdin.") t = _get_cases(ctx) - data = t.add_note(case_number, text.strip(), note_type=note_type) + data = t.add_note(case_number, text.strip(), note_type=note_type, is_public=is_public) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# update-note +# --------------------------------------------------------------------------- + +@group.command("update-note") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") +@click.option("--event-id", required=True, help="Event ID of the note.") +@click.option("--is-public/--no-is-public", required=True, + help="Set note visibility for stakeholders.") +@pass_context +def update_note(ctx, case_number, event_id, is_public) -> None: + """Toggle a note's public/private visibility. + + Examples: + limacharlie case update-note --case-number 42 --event-id --is-public + limacharlie case update-note --case-number 42 --event-id --no-is-public + """ + t = _get_cases(ctx) + data = t.update_note_visibility(case_number, event_id, is_public) _output(ctx, data) @@ -1409,6 +1461,25 @@ def assignees(ctx) -> None: _output(ctx, data) +# --------------------------------------------------------------------------- +# orgs +# --------------------------------------------------------------------------- + +@group.command() +@pass_context +def orgs(ctx) -> None: + """List organizations subscribed to ext-cases. + + Returns OIDs the current user can access that have cases enabled. + + Example: + limacharlie case orgs + """ + t = _get_cases(ctx) + data = t.list_orgs() + _output(ctx, data) + + # --------------------------------------------------------------------------- # tag (nested group) # --------------------------------------------------------------------------- diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index b987074..a25c4df 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -193,11 +193,23 @@ def add_note( case_number: int, content: str, note_type: str | None = None, + is_public: bool | None = None, ) -> dict[str, Any]: - """Add a note to a case.""" + """Add a note to a case. + + Args: + case_number: Case number. + content: Note content (max 8192 chars). + note_type: Note category (general, analysis, remediation, + escalation, handoff, to_stakeholder, from_stakeholder). + is_public: Whether the note is visible to stakeholders + (default false). + """ body: dict[str, Any] = {"content": content} if note_type: body["note_type"] = note_type + if is_public is not None: + body["is_public"] = is_public return self._request( "POST", f"cases/{case_number}/notes", @@ -205,6 +217,26 @@ def add_note( body=body, ) + def update_note_visibility( + self, + case_number: int, + event_id: str, + is_public: bool, + ) -> dict[str, Any]: + """Update a note's public visibility. + + Args: + case_number: Case number. + event_id: The event ID of the note to update. + is_public: Whether the note is visible to stakeholders. + """ + return self._request( + "PATCH", + f"cases/{case_number}/notes/{event_id}", + query_params={"oid": self.oid}, + body={"is_public": is_public}, + ) + def bulk_update( self, case_numbers: list[int], @@ -593,3 +625,11 @@ def list_assignees(self) -> dict[str, Any]: "assignees", query_params={"oids": self.oid}, ) + + # ------------------------------------------------------------------ + # Orgs + # ------------------------------------------------------------------ + + def list_orgs(self) -> dict[str, Any]: + """List organizations subscribed to ext-cases that the caller can access.""" + return self._request("GET", "orgs") diff --git a/tests/unit/test_cli_case.py b/tests/unit/test_cli_case.py index 64aa14c..363d1c4 100644 --- a/tests/unit/test_cli_case.py +++ b/tests/unit/test_cli_case.py @@ -35,7 +35,7 @@ def _invoke(args, mock_cases_cls, return_value=None): for name in [ "create_case", "list_cases", "get_case", "export_case", - "update_case", "add_note", + "update_case", "add_note", "update_note_visibility", "bulk_update", "merge", "list_detections", "add_detection", "remove_detection", "list_entities", "add_entity", "update_entity", "remove_entity", @@ -43,7 +43,7 @@ def _invoke(args, mock_cases_cls, return_value=None): "list_telemetry", "add_telemetry", "update_telemetry", "remove_telemetry", "list_artifacts", "add_artifact", "remove_artifact", "report_summary", "dashboard_counts", - "get_config", "set_config", "list_assignees", + "get_config", "set_config", "list_assignees", "list_orgs", ]: getattr(mock_t, name).return_value = return_value runner = CliRunner() @@ -62,10 +62,11 @@ def test_case_group_help(self): result = runner.invoke(cli, ["case", "--help"]) assert result.exit_code == 0 assert "Manage SOC cases" in result.output - for cmd in ["create", "list", "get", "export", "update", "add-note", "merge", + for cmd in ["create", "list", "get", "export", "update", "add-note", + "update-note", "merge", "entity", "telemetry", "artifact", "detection", "tag", "report", "dashboard", "config-get", "config-set", - "assignees", "bulk-update"]: + "assignees", "orgs", "bulk-update"]: assert cmd in result.output def test_entity_subgroup_help(self): @@ -315,7 +316,7 @@ def test_export_by_id(self): export_data = { "case": {"case_id": "tid-1", "status": "new"}, "events": [{"type": "created"}], - "detections": {"detections": [{"detection_id": "det-1"}]}, + "detections": {"detections": [{"detect_id": "det-1"}]}, "entities": {"entities": [{"entity_type": "ip"}]}, "telemetry": {"telemetry": []}, "artifacts": {"artifacts": []}, @@ -340,7 +341,7 @@ def test_export_with_data_creates_directory(self, tmp_path): export_data = { "case": {"case_id": "tid-1"}, "events": [], - "detections": {"detections": [{"detection_id": "det-1"}]}, + "detections": {"detections": [{"detect_id": "det-1"}]}, "entities": {"entities": []}, "telemetry": {"telemetry": [{"atom": "atom-1", "sid": "sid-1"}]}, "artifacts": {"artifacts": [{"artifact_id": "art-1"}]}, @@ -400,7 +401,7 @@ def test_export_with_data_skips_on_fetch_error(self, tmp_path): export_data = { "case": {"case_id": "tid-1"}, "events": [], - "detections": {"detections": [{"detection_id": "det-bad"}]}, + "detections": {"detections": [{"detect_id": "det-bad"}]}, "entities": {"entities": []}, "telemetry": {"telemetry": []}, "artifacts": {"artifacts": []}, @@ -552,7 +553,7 @@ def test_export_with_data_artifact_fetch_error(self, tmp_path): assert "Warning" in result.output def test_export_with_data_skips_empty_ids(self, tmp_path): - """Entries missing detection_id/atom/sid/artifact_id are skipped.""" + """Entries missing detect_id/atom/sid/artifact_id are skipped.""" out_dir = str(tmp_path / "export-empty") export_data = { "case": {"case_id": "tid-1"}, @@ -646,7 +647,7 @@ def test_add_note_with_content(self): return_value={}, ) assert result.exit_code == 0 - mock_t.add_note.assert_called_once_with(42, "Triage complete", note_type=None) + mock_t.add_note.assert_called_once_with(42, "Triage complete", note_type=None, is_public=None) def test_add_note_with_type(self): p1, p2, p3 = _patch_cases() @@ -657,7 +658,29 @@ def test_add_note_with_type(self): return_value={}, ) assert result.exit_code == 0 - mock_t.add_note.assert_called_once_with(42, "Analysis", note_type="analysis") + mock_t.add_note.assert_called_once_with(42, "Analysis", note_type="analysis", is_public=None) + + def test_add_note_with_is_public(self): + p1, p2, p3 = _patch_cases() + with p1, p2, p3 as mock_t_cls: + result, mock_t = _invoke( + ["case", "add-note", "--case-number", "42", "--content", "Public note", "--is-public"], + mock_t_cls, + return_value={}, + ) + assert result.exit_code == 0 + mock_t.add_note.assert_called_once_with(42, "Public note", note_type=None, is_public=True) + + def test_add_note_with_no_is_public(self): + p1, p2, p3 = _patch_cases() + with p1, p2, p3 as mock_t_cls: + result, mock_t = _invoke( + ["case", "add-note", "--case-number", "42", "--content", "Private note", "--no-is-public"], + mock_t_cls, + return_value={}, + ) + assert result.exit_code == 0 + mock_t.add_note.assert_called_once_with(42, "Private note", note_type=None, is_public=False) def test_add_note_from_stdin(self): p1, p2, p3 = _patch_cases() @@ -672,7 +695,7 @@ def test_add_note_from_stdin(self): input="Piped content\n", ) assert result.exit_code == 0 - mock_t.add_note.assert_called_once_with(42, "Piped content", note_type=None) + mock_t.add_note.assert_called_once_with(42, "Piped content", note_type=None, is_public=None) def test_add_note_invalid_type_rejected(self): runner = CliRunner() @@ -682,6 +705,49 @@ def test_add_note_invalid_type_rejected(self): assert result.exit_code != 0 +# --------------------------------------------------------------------------- +# case update-note +# --------------------------------------------------------------------------- + + +class TestCaseUpdateNote: + def test_update_note_public(self): + p1, p2, p3 = _patch_cases() + with p1, p2, p3 as mock_t_cls: + result, mock_t = _invoke( + ["case", "update-note", "--case-number", "42", "--event-id", "evt-1", "--is-public"], + mock_t_cls, + return_value={}, + ) + assert result.exit_code == 0 + mock_t.update_note_visibility.assert_called_once_with(42, "evt-1", True) + + def test_update_note_private(self): + p1, p2, p3 = _patch_cases() + with p1, p2, p3 as mock_t_cls: + result, mock_t = _invoke( + ["case", "update-note", "--case-number", "42", "--event-id", "evt-1", "--no-is-public"], + mock_t_cls, + return_value={}, + ) + assert result.exit_code == 0 + mock_t.update_note_visibility.assert_called_once_with(42, "evt-1", False) + + def test_update_note_requires_event_id(self): + runner = CliRunner() + result = runner.invoke(cli, [ + "case", "update-note", "--case-number", "1", "--is-public", + ]) + assert result.exit_code != 0 + + def test_update_note_requires_visibility_flag(self): + runner = CliRunner() + result = runner.invoke(cli, [ + "case", "update-note", "--case-number", "1", "--event-id", "evt-1", + ]) + assert result.exit_code != 0 + + # --------------------------------------------------------------------------- # case bulk-update # --------------------------------------------------------------------------- @@ -1218,6 +1284,24 @@ def test_assignees(self): mock_t.list_assignees.assert_called_once() +# --------------------------------------------------------------------------- +# case orgs +# --------------------------------------------------------------------------- + + +class TestCaseOrgs: + def test_orgs(self): + p1, p2, p3 = _patch_cases() + with p1, p2, p3 as mock_t_cls: + result, mock_t = _invoke( + ["case", "orgs"], + mock_t_cls, + return_value={"oids": ["org-1", "org-2"]}, + ) + assert result.exit_code == 0 + mock_t.list_orgs.assert_called_once() + + # --------------------------------------------------------------------------- # Quiet mode # --------------------------------------------------------------------------- diff --git a/tests/unit/test_sdk_cases.py b/tests/unit/test_sdk_cases.py index 9a83e82..64398a1 100644 --- a/tests/unit/test_sdk_cases.py +++ b/tests/unit/test_sdk_cases.py @@ -302,6 +302,41 @@ def test_none_note_type_excluded(self, cases, mock_org): body = _extract_body(mock_org) assert "note_type" not in body + def test_with_is_public_true(self, cases, mock_org): + mock_org.client.request.return_value = {} + cases.add_note(42, "Public note", is_public=True) + body = _extract_body(mock_org) + assert body == {"content": "Public note", "is_public": True} + + def test_with_is_public_false(self, cases, mock_org): + mock_org.client.request.return_value = {} + cases.add_note(42, "Private note", is_public=False) + body = _extract_body(mock_org) + assert body == {"content": "Private note", "is_public": False} + + def test_is_public_none_excluded(self, cases, mock_org): + mock_org.client.request.return_value = {} + cases.add_note(42, "Note text", is_public=None) + body = _extract_body(mock_org) + assert "is_public" not in body + + +class TestUpdateNoteVisibility: + def test_set_public(self, cases, mock_org): + mock_org.client.request.return_value = {} + cases.update_note_visibility(42, "evt-1", True) + args, kwargs = _extract_call(mock_org) + assert args == ("PATCH", "api/v1/cases/42/notes/evt-1") + assert kwargs["query_params"] == {"oid": "test-oid"} + body = json.loads(kwargs["raw_body"]) + assert body == {"is_public": True} + + def test_set_private(self, cases, mock_org): + mock_org.client.request.return_value = {} + cases.update_note_visibility(42, "evt-1", False) + body = _extract_body(mock_org) + assert body == {"is_public": False} + class TestBulkUpdate: def test_body_structure(self, cases, mock_org): @@ -714,6 +749,25 @@ def test_path_and_query(self, cases, mock_org): assert kwargs["query_params"] == {"oids": "test-oid"} +# --------------------------------------------------------------------------- +# Orgs +# --------------------------------------------------------------------------- + + +class TestListOrgs: + def test_path(self, cases, mock_org): + mock_org.client.request.return_value = {"oids": ["org-1"]} + cases.list_orgs() + args, kwargs = _extract_call(mock_org) + assert args == ("GET", "api/v1/orgs") + + def test_no_query_params(self, cases, mock_org): + mock_org.client.request.return_value = {"oids": []} + cases.list_orgs() + _, kwargs = _extract_call(mock_org) + assert kwargs["query_params"] is None + + # --------------------------------------------------------------------------- # _request internals # --------------------------------------------------------------------------- From ec82b1bf6219eb07210cdb9dcb5c9bf3ddc3a0f3 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Fri, 3 Apr 2026 11:30:32 -0700 Subject: [PATCH 2/2] fix: add orgs and update-note to case subcommand regression test Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/test_cli_lazy_loading_regression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index e3fac1d..d9ec3ef 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -130,8 +130,8 @@ "case": frozenset({ "add-note", "artifact", "assignees", "bulk-update", "config-get", "config-set", "create", "dashboard", "detection", - "entity", "export", "get", "list", "merge", "report", "tag", - "telemetry", "update", + "entity", "export", "get", "list", "merge", "orgs", "report", + "tag", "telemetry", "update", "update-note", }), "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "set"}), "detection": frozenset({"get", "list"}),