From f48d7624554a29ae2073153c89a201188e5eca46 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 7 Nov 2026 15:46:34 -0800 Subject: [PATCH 1/5] Add case note editing and confirmed redaction to SDK and CLI --- doc/cli/other-commands.md | 34 ++++ limacharlie/commands/case_cmd.py | 84 ++++++++++ limacharlie/discovery.py | 6 +- limacharlie/sdk/cases.py | 54 ++++++ tests/unit/test_case_note_mutations.py | 157 ++++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 2 +- 6 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_case_note_mutations.py diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index c79ad41f..f9053b66 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -132,3 +132,37 @@ limacharlie dr create --ai-help - [CLI Overview](README.md) — Global options and output formats - [Getting Started](../getting-started.md) — Installation and first steps + +## Case note editing and redaction + +Use `case get --case-number 42` to find the original note event ID in the +case timeline. Both actions require `investigation.set` and use the selected +organization (`--oid`), including its staging Cases URL when applicable. + +```bash +limacharlie --oid case edit-note --case-number 42 --event-id --content "Corrected text" +limacharlie --oid case edit-note --case-number 42 --event-id --input-file note.txt +cat note.txt | limacharlie --oid case edit-note --case-number 42 --event-id +limacharlie --oid case redact-note --case-number 42 --event-id --confirm +``` + +Editing accepts one text source: `--content`, `--input-file` (UTF-8 text, +`-` for stdin), or stdin. Text must be nonempty and at most **8192 UTF-8 bytes**. +Earlier text, editor and time stay in case history. A note permits **100 actual +edits**; identical text is a no-op. After the limit, add a new note. +Both commands return `{event}`; redaction also returns +`images_pending_deletion`. JSON/YAML output preserves these fields. + +Redaction is irreversible: it replaces the note with `[redacted]`, removes +all earlier text from active Cases storage and blocks image downloads. +Redacted notes cannot be edited or have their visibility changed. A nonzero +`images_pending_deletion` means image blobs still await cleanup; it does not +mean every image has been deleted. Repeating redaction retries cleanup. + +Redaction deletes active records, not recovery copies: text may remain in +point-in-time recovery for up to **7 days** and backups for up to **30 days**. +Already delivered hooks-domain audit events follow the organization's retention +and cannot be recalled. Downloaded images, existing client copies, queued old +payloads and logs cannot be recalled either. Text submitted through extension +actions can enter Cloud Logging; redaction does not rewrite old logs or change +their retention. See `case edit-note --ai-help` and `case redact-note --ai-help`. diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index f397ec12..fba6262c 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -130,6 +130,33 @@ echo "Handoff notes" | limacharlie case add-note --case-number 42 --type handoff """ +_EXPLAIN_EDIT_NOTE = """\ +Replace a note's text, keeping earlier text and editor/time in case history. +Get the original note's event ID with case get --case-number . +Provide one source: --content, --input-file (UTF-8 text), or stdin. +Content must be nonempty and at most 8192 UTF-8 bytes. The API permits +100 actual edits per note; identical text is a no-op. After the limit, +add a new note. Redacted notes cannot be edited. Requires investigation.set. + +Example: + limacharlie case edit-note --case-number 42 --event-id --content "Corrected notes" +""" + +_EXPLAIN_REDACT_NOTE = """\ +Irreversibly replace a note with [redacted] and delete its earlier text +from active Cases storage. Requires --confirm and investigation.set. +Redacted notes cannot be edited or have their visibility changed. +Image downloads are blocked immediately; images_pending_deletion reports +blobs still awaiting cleanup. Repeating redaction retries pending cleanup. +Text can remain in point-in-time recovery for up to 7 days and backups for +up to 30 days. Delivered audit events, downloaded images, existing client +copies, queued payloads and logs cannot be recalled. Text submitted through +extension actions can enter Cloud Logging; redaction does not rewrite logs. + +Example: + limacharlie case redact-note --case-number 42 --event-id --confirm +""" + _EXPLAIN_UPDATE_NOTE_VISIBILITY = """\ Toggle a note's public/private visibility. Public notes are visible to stakeholders. @@ -459,6 +486,8 @@ 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.edit-note", _EXPLAIN_EDIT_NOTE) +register_explain("case.redact-note", _EXPLAIN_REDACT_NOTE) register_explain("case.bulk-update", _EXPLAIN_BULK_UPDATE) register_explain("case.merge", _EXPLAIN_MERGE) register_explain("case.entity.list", _EXPLAIN_ENTITY_LIST) @@ -891,6 +920,61 @@ def add_note(ctx, case_number, content, input_file, note_type, is_public) -> Non _output(ctx, data) +@group.command("edit-note") +@click.option("--case-number", required=True, type=int, help="Case number.") +@click.option("--event-id", required=True, help="Original note event ID from case get.") +@click.option("--content", default=None, help="Replacement text (nonempty, max 8192 UTF-8 bytes).") +@click.option("--input-file", type=click.File("r", encoding="utf-8"), + help="Read replacement text from a UTF-8 file; use - for stdin.") +@pass_context +def edit_note(ctx, case_number, event_id, content, input_file) -> None: + """Edit note text, preserving earlier versions in case history. + + Provide --content, --input-file, or stdin. At most 100 actual edits + per note are allowed. Redacted notes cannot be edited. + """ + if content is not None and input_file is not None: + raise click.UsageError("Choose either --content or --input-file.") + if content is not None: + text = content + elif input_file is not None: + text = input_file.read() + elif not sys.stdin.isatty(): + text = sys.stdin.read() + else: + raise click.UsageError("Provide content via --content, --input-file, or stdin.") + if not text.strip(): + raise click.UsageError("Note content must not be empty; provide replacement text.") + if len(text.encode("utf-8")) > 8192: + raise click.UsageError("Note content exceeds 8192 UTF-8 bytes; shorten the text.") + data = _get_cases(ctx).edit_note(case_number, event_id, text) + _output(ctx, data) + + +@group.command("redact-note") +@click.option("--case-number", required=True, type=int, help="Case number.") +@click.option("--event-id", required=True, help="Original note event ID from case get.") +@click.option("--confirm", is_flag=True, help="Confirm irreversible note redaction (required).") +@pass_context +def redact_note(ctx, case_number, event_id, confirm) -> None: + """Irreversibly redact note text, versions and attached images. + + Requires --confirm. The response reports images_pending_deletion; + a nonzero count means blob cleanup is still pending. + See --ai-help for recovery-copy retention and external-copy limits. + """ + if not confirm: + click.echo( + "Error: Destructive operation requires --confirm flag.\n" + "Suggestion: Re-run with --confirm to irreversibly redact the note.", + err=True, + ) + ctx.exit(4) + return + data = _get_cases(ctx).redact_note(case_number, event_id) + _output(ctx, data) + + # --------------------------------------------------------------------------- # log-time # --------------------------------------------------------------------------- diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index 87ec8d42..037486ab 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -100,7 +100,7 @@ "task send", "task request", "sensor dump", "sensor sweep", "yara scan", - "case list", "case get", "case update", "case add-note", + "case list", "case get", "case update", "case add-note", "case edit-note", "case redact-note", "case entity add", "case entity search", "mailsec message list", "mailsec message get", "mailsec message action", "mailsec message bulk-action", "mailsec campaign action", @@ -189,7 +189,7 @@ "description": "SOC case lifecycle, investigation tracking, and reporting", "commands": [ "case list", "case get", "case update", - "case add-note", "case bulk-update", "case merge", + "case add-note", "case edit-note", "case redact-note", "case bulk-update", "case merge", "case entity list", "case entity add", "case entity update", "case entity remove", "case entity search", "case telemetry list", "case telemetry add", @@ -205,7 +205,7 @@ "investigation_management": { "description": "Case workflows, analyst feedback, notes, and investigation artifacts", "commands": [ - "case list", "case get", "case update", "case add-note", + "case list", "case get", "case update", "case add-note", "case edit-note", "case redact-note", "case bulk-update", "case merge", "case report", "case dashboard", "feedback request-approval", "feedback request-ack", "feedback request-question", "feedback channel list", "feedback channel add", "feedback channel remove", diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index fc2d4c06..0d16ecac 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -224,6 +224,60 @@ def add_note( body=body, ) + def edit_note( + self, case_number: int, event_id: str, content: str, + ) -> dict[str, Any]: + """Edit a note while preserving earlier text in the case history. + + The API permits 100 actual edits per note; identical text is a no-op. + Redacted notes cannot be edited. Requires investigation.set. + + Args: + case_number: Case number. + event_id: Original note event ID from the case timeline. + content: Nonempty replacement text, at most 8192 UTF-8 bytes. + + Returns: + dict: Updated note in ``event``. + + Raises: + LimaCharlieError: If access is denied, the note is missing, + content is invalid, or the note is redacted or at its edit limit. + """ + return self._request( + "PUT", + f"cases/{case_number}/notes/{event_id}/content", + query_params={"oid": self.oid}, + body={"content": content}, + ) + + def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: + """Irreversibly redact a note and its earlier versions from active storage. + + Redacted notes cannot be edited or have their visibility changed. + Repeating this call retries pending image deletion. Requires + investigation.set. Recovery copies can retain text for up to 7 days + in point-in-time recovery and 30 days in backups. Delivered audit + events, downloaded images, client copies and logs cannot be recalled. + + Args: + case_number: Case number. + event_id: Original note event ID from the case timeline. + + Returns: + dict: Redacted ``event`` and ``images_pending_deletion`` count. + A nonzero count means image blob cleanup is still pending; + image downloads are already blocked. + + Raises: + LimaCharlieError: If access is denied or the case/note is missing. + """ + return self._request( + "POST", + f"cases/{case_number}/notes/{event_id}/redact", + query_params={"oid": self.oid}, + ) + def log_time( self, case_number: int, diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py new file mode 100644 index 00000000..7cdf3d1c --- /dev/null +++ b/tests/unit/test_case_note_mutations.py @@ -0,0 +1,157 @@ +"""Contracts for editing and irreversibly redacting case notes.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from limacharlie.cli import cli, main +from limacharlie.errors import LimaCharlieError, NotFoundError, PermissionDeniedError +from limacharlie.sdk.cases import Cases + + +@pytest.fixture +def org(): + org = MagicMock() + org.oid = "test-oid" + org.get_urls.return_value = {"cases": "cases-staging.limacharlie.io"} + return org + + +@pytest.mark.parametrize("method,verb,suffix,body,response", [ + ("edit_note", "PUT", "content", {"content": "修正\n"}, {"event": {"event_id": "evt-1"}}), + ("redact_note", "POST", "redact", None, + {"event": {"redacted": True}, "images_pending_deletion": 2}), +]) +def test_sdk_request_and_response(org, method, verb, suffix, body, response): + org.client.request.return_value = response + args = (42, "evt-1") + ((body["content"],) if body else ()) + assert getattr(Cases(org), method)(*args) == response + call_args, kwargs = org.client.request.call_args + assert call_args == (verb, f"api/v1/cases/42/notes/evt-1/{suffix}") + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert kwargs["query_params"] == {"oid": "test-oid"} + if body: + assert json.loads(kwargs["raw_body"]) == body + assert kwargs["content_type"] == "application/json" + else: + assert "raw_body" not in kwargs + + +@pytest.mark.parametrize("method", ["edit_note", "redact_note"]) +@pytest.mark.parametrize("error", [NotFoundError("Note missing"), + PermissionDeniedError("Access denied"), LimaCharlieError("Already redacted", code=409)]) +def test_sdk_propagates_errors(org, method, error): + org.client.request.side_effect = error + args = (42, "evt-1", "text") if method == "edit_note" else (42, "evt-1") + with pytest.raises(type(error)) as exc: + getattr(Cases(org), method)(*args) + assert exc.value is error + + +@pytest.fixture +def sdk(): + with patch("limacharlie.commands.case_cmd.Client") as client, \ + patch("limacharlie.commands.case_cmd.Organization"), \ + patch("limacharlie.commands.case_cmd.Cases") as cls: + cls.return_value.edit_note.return_value = {"event": {"event_id": "evt-1"}} + cls.return_value.redact_note.return_value = { + "event": {"redacted": True}, "images_pending_deletion": 2, + } + yield cls.return_value, client + + +def invoke(command, extra=(), stdin=None, output="json"): + return CliRunner().invoke(cli, ["--oid", "test-oid", "--output", output, + "case", command, "--case-number", "42", "--event-id", "evt-1", *extra], input=stdin) + + +@pytest.mark.parametrize("source", ["content", "file", "stdin", "dash"]) +def test_cli_edit_sources(sdk, tmp_path, source): + text = " 修正\nkeep formatting\n" + path = tmp_path / "note.txt" + path.write_text(text, encoding="utf-8") + extra = {"content": ["--content", text], "file": ["--input-file", str(path)], + "stdin": [], "dash": ["--input-file", "-"]}[source] + result = invoke("edit-note", extra, stdin=text) + assert result.exit_code == 0, result.output + sdk[0].edit_note.assert_called_once_with(42, "evt-1", text) + assert json.loads(result.output) == {"event": {"event_id": "evt-1"}} + assert sdk[1].call_args.kwargs["oid"] == "test-oid" + + +@pytest.mark.parametrize("text", ["", " \n", "é" * 4097]) +def test_cli_edit_invalid_content_never_calls_sdk(sdk, text): + result = invoke("edit-note", ["--content", text]) + assert result.exit_code != 0 + assert "empty" in result.output or "8192 UTF-8 bytes" in result.output + sdk[0].edit_note.assert_not_called() + + +def test_cli_edit_exact_byte_limit(sdk): + text = "é" * 4096 + assert invoke("edit-note", ["--content", text]).exit_code == 0 + sdk[0].edit_note.assert_called_once_with(42, "evt-1", text) + + +def test_cli_edit_conflicting_sources(sdk, tmp_path): + path = tmp_path / "note.txt" + path.write_text("file") + result = invoke("edit-note", ["--content", "text", "--input-file", str(path)]) + assert result.exit_code != 0 + assert "Choose either" in result.output + sdk[0].edit_note.assert_not_called() + + +def test_cli_edit_missing_input(sdk): + result = invoke("edit-note") + assert result.exit_code != 0 + assert "must not be empty" in result.output + sdk[0].edit_note.assert_not_called() + + +def test_cli_redact_requires_confirm(sdk): + result = invoke("redact-note") + assert result.exit_code == 4 + assert "requires --confirm" in result.output + sdk[0].redact_note.assert_not_called() + sdk[1].assert_not_called() + + +@pytest.mark.parametrize("pending", [0, 2]) +@pytest.mark.parametrize("output", ["json", "table"]) +def test_cli_redact_pending_cleanup_output(sdk, pending, output): + sdk[0].redact_note.return_value["images_pending_deletion"] = pending + result = invoke("redact-note", ["--confirm"], output=output) + assert result.exit_code == 0, result.output + assert "images_pending_deletion" in result.output + assert str(pending) in result.output + if output == "json": + assert json.loads(result.output)["images_pending_deletion"] == pending + assert "all images deleted" not in result.output.lower() + sdk[0].redact_note.assert_called_once_with(42, "evt-1") + + +@pytest.mark.parametrize("command,extra", [("edit-note", ["--content", "text"]), + ("redact-note", ["--confirm"])]) +@pytest.mark.parametrize("error", [NotFoundError("Note missing"), + PermissionDeniedError("Access denied"), LimaCharlieError("Edit limit reached", code=409)]) +def test_cli_errors(sdk, command, extra, error): + getattr(sdk[0], command.replace("-", "_")).side_effect = error + argv = ["limacharlie", "--oid", "test-oid", "case", command, + "--case-number", "42", "--event-id", "evt-1", *extra] + with CliRunner().isolation() as streams, patch("sys.argv", argv): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == error.exit_code + assert error.raw_message in streams[0].getvalue().decode() + + +@pytest.mark.parametrize("command", ["edit-note", "redact-note"]) +def test_cli_explain_and_discovery(command): + from limacharlie.discovery import PROFILES + result = CliRunner().invoke(cli, ["case", command, "--ai-help"]) + assert result.exit_code == 0, result.output + assert "8192" in result.output if command == "edit-note" else "30 days" in result.output + assert f"case {command}" in PROFILES["cases"]["commands"] diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index f7dd17ee..d9718f44 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -146,7 +146,7 @@ "billing": frozenset({"details", "invoice", "plans", "status"}), "case": frozenset({ "add-note", "artifact", "assignees", "bulk-update", - "config-get", "config-set", "create", "dashboard", "detection", + "config-get", "config-set", "create", "dashboard", "detection", "edit-note", "redact-note", "entity", "export", "get", "list", "log-time", "merge", "orgs", "report", "tag", "telemetry", "update", "update-note", }), From 07d5ab76fca187c02621041f1c05506e8e3d8fd5 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 7 Nov 2026 15:53:04 -0800 Subject: [PATCH 2/5] Address note command review: trim content and encode event IDs --- doc/cli/other-commands.md | 16 ++++++---- limacharlie/commands/case_cmd.py | 8 +++-- limacharlie/sdk/cases.py | 9 ++++-- tests/unit/test_case_note_mutations.py | 41 ++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index f9053b66..8958c8d7 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -128,11 +128,6 @@ limacharlie cheatsheet incident-response limacharlie dr create --ai-help ``` -## See Also - -- [CLI Overview](README.md) — Global options and output formats -- [Getting Started](../getting-started.md) — Installation and first steps - ## Case note editing and redaction Use `case get --case-number 42` to find the original note event ID in the @@ -147,9 +142,13 @@ limacharlie --oid case redact-note --case-number 42 --event-id --con ``` Editing accepts one text source: `--content`, `--input-file` (UTF-8 text, -`-` for stdin), or stdin. Text must be nonempty and at most **8192 UTF-8 bytes**. +`-` for stdin), or stdin. Leading/trailing whitespace is stripped, matching +`add-note`, before checking that text is nonempty and at most **8192 UTF-8 bytes**. Earlier text, editor and time stay in case history. A note permits **100 actual edits**; identical text is a no-op. After the limit, add a new note. +Malformed event IDs return HTTP 400. Transient transaction failures return +HTTP 503; retry the operation. `case update-note` visibility changes on a +redacted note return HTTP 409. Both commands return `{event}`; redaction also returns `images_pending_deletion`. JSON/YAML output preserves these fields. @@ -166,3 +165,8 @@ and cannot be recalled. Downloaded images, existing client copies, queued old payloads and logs cannot be recalled either. Text submitted through extension actions can enter Cloud Logging; redaction does not rewrite old logs or change their retention. See `case edit-note --ai-help` and `case redact-note --ai-help`. + +## See Also + +- [CLI Overview](README.md) — Global options and output formats +- [Getting Started](../getting-started.md) — Installation and first steps diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index fba6262c..1fc60ec0 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -134,6 +134,7 @@ Replace a note's text, keeping earlier text and editor/time in case history. Get the original note's event ID with case get --case-number . Provide one source: --content, --input-file (UTF-8 text), or stdin. +Leading/trailing whitespace is stripped, matching add-note. Content must be nonempty and at most 8192 UTF-8 bytes. The API permits 100 actual edits per note; identical text is a no-op. After the limit, add a new note. Redacted notes cannot be edited. Requires investigation.set. @@ -159,7 +160,7 @@ _EXPLAIN_UPDATE_NOTE_VISIBILITY = """\ Toggle a note's public/private visibility. Public notes are visible -to stakeholders. +to stakeholders. Redacted notes reject visibility changes with HTTP 409. Requires the event ID of the note (from the case event timeline). @@ -943,7 +944,8 @@ def edit_note(ctx, case_number, event_id, content, input_file) -> None: text = sys.stdin.read() else: raise click.UsageError("Provide content via --content, --input-file, or stdin.") - if not text.strip(): + text = text.strip() + if not text: raise click.UsageError("Note content must not be empty; provide replacement text.") if len(text.encode("utf-8")) > 8192: raise click.UsageError("Note content exceeds 8192 UTF-8 bytes; shorten the text.") @@ -1026,6 +1028,8 @@ def log_time(ctx, case_number, billable_seconds, cost_profile, assignee, note) - def update_note(ctx, case_number, event_id, is_public) -> None: """Toggle a note's public/private visibility. + Redacted notes reject visibility changes with HTTP 409. + Examples: limacharlie case update-note --case-number 42 --event-id --is-public limacharlie case update-note --case-number 42 --event-id --no-is-public diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index 0d16ecac..bbb04ac7 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -10,6 +10,7 @@ import json import os from typing import Any, TYPE_CHECKING +from urllib.parse import quote if TYPE_CHECKING: from .organization import Organization @@ -243,10 +244,12 @@ def edit_note( Raises: LimaCharlieError: If access is denied, the note is missing, content is invalid, or the note is redacted or at its edit limit. + Malformed event IDs return HTTP 400; transient transaction + failures return HTTP 503 and can be retried. """ return self._request( "PUT", - f"cases/{case_number}/notes/{event_id}/content", + f"cases/{case_number}/notes/{quote(event_id, safe='')}/content", query_params={"oid": self.oid}, body={"content": content}, ) @@ -271,10 +274,12 @@ def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: Raises: LimaCharlieError: If access is denied or the case/note is missing. + Malformed event IDs return HTTP 400; transient transaction + failures return HTTP 503 and can be retried. """ return self._request( "POST", - f"cases/{case_number}/notes/{event_id}/redact", + f"cases/{case_number}/notes/{quote(event_id, safe='')}/redact", query_params={"oid": self.oid}, ) diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py index 7cdf3d1c..b63ec1e3 100644 --- a/tests/unit/test_case_note_mutations.py +++ b/tests/unit/test_case_note_mutations.py @@ -56,6 +56,7 @@ def sdk(): patch("limacharlie.commands.case_cmd.Organization"), \ patch("limacharlie.commands.case_cmd.Cases") as cls: cls.return_value.edit_note.return_value = {"event": {"event_id": "evt-1"}} + cls.return_value.add_note.return_value = {"event": {"event_id": "evt-1"}} cls.return_value.redact_note.return_value = { "event": {"redacted": True}, "images_pending_deletion": 2, } @@ -76,12 +77,12 @@ def test_cli_edit_sources(sdk, tmp_path, source): "stdin": [], "dash": ["--input-file", "-"]}[source] result = invoke("edit-note", extra, stdin=text) assert result.exit_code == 0, result.output - sdk[0].edit_note.assert_called_once_with(42, "evt-1", text) + sdk[0].edit_note.assert_called_once_with(42, "evt-1", text.strip()) assert json.loads(result.output) == {"event": {"event_id": "evt-1"}} assert sdk[1].call_args.kwargs["oid"] == "test-oid" -@pytest.mark.parametrize("text", ["", " \n", "é" * 4097]) +@pytest.mark.parametrize("text", ["", " \n", "é" * 4097, " " + "é" * 4097 + "\n"]) def test_cli_edit_invalid_content_never_calls_sdk(sdk, text): result = invoke("edit-note", ["--content", text]) assert result.exit_code != 0 @@ -155,3 +156,39 @@ def test_cli_explain_and_discovery(command): assert result.exit_code == 0, result.output assert "8192" in result.output if command == "edit-note" else "30 days" in result.output assert f"case {command}" in PROFILES["cases"]["commands"] + + +@pytest.mark.parametrize("method,suffix", [("edit_note", "content"), ("redact_note", "redact")]) +def test_sdk_encodes_event_id_as_single_path_segment(org, method, suffix): + args = (42, "../note/redact?oid=other%#fragment") + if method == "edit_note": + args += ("text",) + getattr(Cases(org), method)(*args) + assert org.client.request.call_args.args[1] == ( + "api/v1/cases/42/notes/..%2Fnote%2Fredact%3Foid%3Dother%25%23fragment/" + suffix + ) + assert org.client.request.call_args.kwargs["query_params"] == {"oid": "test-oid"} + + +@pytest.mark.parametrize("source", ["content", "file", "stdin"]) +def test_cli_trims_before_byte_limit_matching_add_note(sdk, tmp_path, source): + text = " " + "é" * 4096 + "\n" + path = tmp_path / "note.txt" + path.write_text(text, encoding="utf-8") + extra = {"content": ["--content", text], "file": ["--input-file", str(path)], + "stdin": []}[source] + result = invoke("edit-note", extra, stdin=text) + assert result.exit_code == 0, result.output + sdk[0].edit_note.assert_called_once_with(42, "evt-1", text.strip()) + added = CliRunner().invoke(cli, ["--oid", "test-oid", "case", "add-note", + "--case-number", "42", *extra], input=text) + assert added.exit_code == 0, added.output + # Both commands submit the same text, so an unchanged file/stdin is a no-op. + assert sdk[0].add_note.call_args.args == (42, text.strip()) + + +def test_update_note_help_explains_redacted_conflict(): + for flag in ("--help", "--ai-help"): + result = CliRunner().invoke(cli, ["case", "update-note", flag]) + assert result.exit_code == 0 + assert "409" in result.output From 60aff75c4bb06a78fbd0dce41251d1acb28877e9 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 7 Nov 2026 16:37:57 -0800 Subject: [PATCH 3/5] Attribute note redaction to the current actor --- doc/cli/other-commands.md | 9 +++++ limacharlie/commands/case_cmd.py | 2 ++ limacharlie/sdk/cases.py | 23 +++++++++++- tests/unit/test_case_note_mutations.py | 50 +++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index 8958c8d7..d439b486 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -158,6 +158,15 @@ Redacted notes cannot be edited or have their visibility changed. A nonzero `images_pending_deletion` means image blobs still await cleanup; it does not mean every image has been deleted. Repeating redaction retries cleanup. +The SDK accepts optional keyword markers for the actor performing redaction: +`cases.redact_note(42, event_id, ai_session_id="session-id", ai_client="client-name")`. +These describe the current actor (human, AI or automation), not the original +note author. Like `add_note`, an omitted or empty `ai_session_id` falls back to +`LC_AI_SESSION_ID`; `ai_client` has no environment fallback. Without either +marker the SDK keeps the existing POST with no body. If a marker is present, +it sends only the nonempty actor markers as JSON. The CLI automatically uses +`LC_AI_SESSION_ID` through the SDK; no new CLI setting is required. + Redaction deletes active records, not recovery copies: text may remain in point-in-time recovery for up to **7 days** and backups for up to **30 days**. Already delivered hooks-domain audit events follow the organization's retention diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index 1fc60ec0..956157fa 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -149,6 +149,8 @@ Redacted notes cannot be edited or have their visibility changed. Image downloads are blocked immediately; images_pending_deletion reports blobs still awaiting cleanup. Repeating redaction retries pending cleanup. +LC_AI_SESSION_ID, when set, attributes redaction to the current AI session +through the SDK, rather than to the original note author. Text can remain in point-in-time recovery for up to 7 days and backups for up to 30 days. Delivered audit events, downloaded images, existing client copies, queued payloads and logs cannot be recalled. Text submitted through diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index bbb04ac7..ced26b8b 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -254,7 +254,14 @@ def edit_note( body={"content": content}, ) - def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: + def redact_note( + self, + case_number: int, + event_id: str, + *, + ai_session_id: str | None = None, + ai_client: str | None = None, + ) -> dict[str, Any]: """Irreversibly redact a note and its earlier versions from active storage. Redacted notes cannot be edited or have their visibility changed. @@ -262,10 +269,17 @@ def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: investigation.set. Recovery copies can retain text for up to 7 days in point-in-time recovery and 30 days in backups. Delivered audit events, downloaded images, client copies and logs cannot be recalled. + With no actor markers, the POST has no body for backward compatibility; + otherwise only nonempty markers are sent as JSON. Args: case_number: Case number. event_id: Original note event ID from the case timeline. + ai_session_id: Session of the actor performing redaction, rather + than the original note author. Falls back to ``LC_AI_SESSION_ID`` + when omitted or empty, matching add_note. + ai_client: Optional client marker for the actor performing redaction + (AI or automation). No environment fallback is applied. Returns: dict: Redacted ``event`` and ``images_pending_deletion`` count. @@ -277,10 +291,17 @@ def redact_note(self, case_number: int, event_id: str) -> dict[str, Any]: Malformed event IDs return HTTP 400; transient transaction failures return HTTP 503 and can be retried. """ + body: dict[str, Any] = {} + session_id = ai_session_id or os.environ.get("LC_AI_SESSION_ID") + if session_id: + body["ai_session_id"] = session_id + if ai_client: + body["ai_client"] = ai_client return self._request( "POST", f"cases/{case_number}/notes/{quote(event_id, safe='')}/redact", query_params={"oid": self.oid}, + body=body or None, ) def log_time( diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py index b63ec1e3..3d41d326 100644 --- a/tests/unit/test_case_note_mutations.py +++ b/tests/unit/test_case_note_mutations.py @@ -12,7 +12,8 @@ @pytest.fixture -def org(): +def org(monkeypatch): + monkeypatch.delenv("LC_AI_SESSION_ID", raising=False) org = MagicMock() org.oid = "test-oid" org.get_urls.return_value = {"cases": "cases-staging.limacharlie.io"} @@ -192,3 +193,50 @@ def test_update_note_help_explains_redacted_conflict(): result = CliRunner().invoke(cli, ["case", "update-note", flag]) assert result.exit_code == 0 assert "409" in result.output + + +@pytest.mark.parametrize("markers,env_session,expected", [ + ({"ai_session_id": "current-session"}, None, {"ai_session_id": "current-session"}), + ({"ai_client": "automation-client"}, None, {"ai_client": "automation-client"}), + ({"ai_session_id": "current-session", "ai_client": "ai-agent"}, None, + {"ai_session_id": "current-session", "ai_client": "ai-agent"}), + ({}, "env-session", {"ai_session_id": "env-session"}), + ({"ai_session_id": "explicit-session"}, "env-session", {"ai_session_id": "explicit-session"}), + ({"ai_session_id": "", "ai_client": "ai-agent"}, "env-session", + {"ai_session_id": "env-session", "ai_client": "ai-agent"}), +]) +def test_redact_actor_markers(org, monkeypatch, markers, env_session, expected): + if env_session is not None: + monkeypatch.setenv("LC_AI_SESSION_ID", env_session) + response = {"event": {"redacted": True}, "images_pending_deletion": 3} + org.client.request.return_value = response + assert Cases(org).redact_note(42, "evt-1", **markers) == response + args, kwargs = org.client.request.call_args + assert args == ("POST", "api/v1/cases/42/notes/evt-1/redact") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert kwargs["content_type"] == "application/json" + assert json.loads(kwargs["raw_body"]) == expected + + +@pytest.mark.parametrize("markers", [{}, {"ai_session_id": "", "ai_client": ""}]) +def test_redact_without_actor_markers_keeps_no_body(org, markers): + Cases(org).redact_note(42, "evt-1", **markers) + kwargs = org.client.request.call_args.kwargs + assert "raw_body" not in kwargs + assert "content_type" not in kwargs + + +def test_cli_redaction_inherits_current_actor_session(org, monkeypatch): + monkeypatch.setenv("LC_AI_SESSION_ID", "current-cli-session") + org.client.request.return_value = { + "event": {"redacted": True}, "images_pending_deletion": 1, + } + with patch("limacharlie.commands.case_cmd.Client", return_value=org.client), \ + patch("limacharlie.commands.case_cmd.Organization", return_value=org): + result = invoke("redact-note", ["--confirm"]) + assert result.exit_code == 0, result.output + assert json.loads(org.client.request.call_args.kwargs["raw_body"]) == { + "ai_session_id": "current-cli-session", + } + assert json.loads(result.output)["images_pending_deletion"] == 1 From 25a11b3503449deab0fcf33fdbdacc92be5a2bd9 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 7 Nov 2026 16:41:26 -0800 Subject: [PATCH 4/5] Clarify outside AI redaction marker attribution --- doc/cli/other-commands.md | 8 +++++--- limacharlie/sdk/cases.py | 7 ++++--- tests/unit/test_case_note_mutations.py | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index d439b486..bb9228f7 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -158,10 +158,12 @@ Redacted notes cannot be edited or have their visibility changed. A nonzero `images_pending_deletion` means image blobs still await cleanup; it does not mean every image has been deleted. Repeating redaction retries cleanup. -The SDK accepts optional keyword markers for the actor performing redaction: +The SDK accepts optional keyword markers for the AI actor performing redaction: `cases.redact_note(42, event_id, ai_session_id="session-id", ai_client="client-name")`. -These describe the current actor (human, AI or automation), not the original -note author. Like `add_note`, an omitted or empty `ai_session_id` falls back to +These identify the current AI actor, not the original note author. +`ai_client` identifies outside AI, such as an MCP server. People and automation +send no AI marker; the service classifies them from the caller's identity. +Like `add_note`, an omitted or empty `ai_session_id` falls back to `LC_AI_SESSION_ID`; `ai_client` has no environment fallback. Without either marker the SDK keeps the existing POST with no body. If a marker is present, it sends only the nonempty actor markers as JSON. The CLI automatically uses diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index ced26b8b..0497b63b 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -270,7 +270,8 @@ def redact_note( in point-in-time recovery and 30 days in backups. Delivered audit events, downloaded images, client copies and logs cannot be recalled. With no actor markers, the POST has no body for backward compatibility; - otherwise only nonempty markers are sent as JSON. + otherwise only nonempty markers are sent as JSON. People and automation + send no AI marker; the service classifies them from caller identity. Args: case_number: Case number. @@ -278,8 +279,8 @@ def redact_note( ai_session_id: Session of the actor performing redaction, rather than the original note author. Falls back to ``LC_AI_SESSION_ID`` when omitted or empty, matching add_note. - ai_client: Optional client marker for the actor performing redaction - (AI or automation). No environment fallback is applied. + ai_client: Optional marker for an outside AI client performing + redaction (for example, MCP). No environment fallback is applied. Returns: dict: Redacted ``event`` and ``images_pending_deletion`` count. diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py index 3d41d326..7eef6d39 100644 --- a/tests/unit/test_case_note_mutations.py +++ b/tests/unit/test_case_note_mutations.py @@ -197,7 +197,7 @@ def test_update_note_help_explains_redacted_conflict(): @pytest.mark.parametrize("markers,env_session,expected", [ ({"ai_session_id": "current-session"}, None, {"ai_session_id": "current-session"}), - ({"ai_client": "automation-client"}, None, {"ai_client": "automation-client"}), + ({"ai_client": "outside-ai-client"}, None, {"ai_client": "outside-ai-client"}), ({"ai_session_id": "current-session", "ai_client": "ai-agent"}, None, {"ai_session_id": "current-session", "ai_client": "ai-agent"}), ({}, "env-session", {"ai_session_id": "env-session"}), From 4a9c08d82d57b940e90e0fa5cc4066072d84b8cd Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 7 Nov 2026 16:43:21 -0800 Subject: [PATCH 5/5] Attribute case note edits to the current editor --- doc/cli/other-commands.md | 10 +++--- limacharlie/commands/case_cmd.py | 2 ++ limacharlie/sdk/cases.py | 18 +++++++++- tests/unit/test_case_note_mutations.py | 49 ++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/doc/cli/other-commands.md b/doc/cli/other-commands.md index bb9228f7..00e05651 100644 --- a/doc/cli/other-commands.md +++ b/doc/cli/other-commands.md @@ -158,15 +158,17 @@ Redacted notes cannot be edited or have their visibility changed. A nonzero `images_pending_deletion` means image blobs still await cleanup; it does not mean every image has been deleted. Repeating redaction retries cleanup. -The SDK accepts optional keyword markers for the AI actor performing redaction: -`cases.redact_note(42, event_id, ai_session_id="session-id", ai_client="client-name")`. +The SDK accepts optional keyword markers for the AI actor editing or redacting: +`cases.redact_note(42, event_id, ai_session_id="session-id", ai_client="client-name")` +or `cases.edit_note(42, event_id, "Corrected text", ai_session_id="session-id", ai_client="client-name")`. These identify the current AI actor, not the original note author. `ai_client` identifies outside AI, such as an MCP server. People and automation send no AI marker; the service classifies them from the caller's identity. Like `add_note`, an omitted or empty `ai_session_id` falls back to `LC_AI_SESSION_ID`; `ai_client` has no environment fallback. Without either -marker the SDK keeps the existing POST with no body. If a marker is present, -it sends only the nonempty actor markers as JSON. The CLI automatically uses +marker, redaction keeps the existing POST with no body and editing keeps +its JSON body containing only `content`. Nonempty markers are added to the +request JSON when present. The CLI automatically uses `LC_AI_SESSION_ID` through the SDK; no new CLI setting is required. Redaction deletes active records, not recovery copies: text may remain in diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index 956157fa..b68b9545 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -138,6 +138,8 @@ Content must be nonempty and at most 8192 UTF-8 bytes. The API permits 100 actual edits per note; identical text is a no-op. After the limit, add a new note. Redacted notes cannot be edited. Requires investigation.set. +LC_AI_SESSION_ID, when set, attributes the edit to the current AI session +through the SDK, rather than to the original note author. Example: limacharlie case edit-note --case-number 42 --event-id --content "Corrected notes" diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index 0497b63b..2d69fedb 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -227,16 +227,26 @@ def add_note( def edit_note( self, case_number: int, event_id: str, content: str, + *, + ai_session_id: str | None = None, + ai_client: str | None = None, ) -> dict[str, Any]: """Edit a note while preserving earlier text in the case history. The API permits 100 actual edits per note; identical text is a no-op. Redacted notes cannot be edited. Requires investigation.set. + People and automation send no AI marker; the service classifies them + from caller identity. With no markers, the body contains only content. Args: case_number: Case number. event_id: Original note event ID from the case timeline. content: Nonempty replacement text, at most 8192 UTF-8 bytes. + ai_session_id: Session of the editor, rather than the original note + author. Falls back to ``LC_AI_SESSION_ID`` when omitted or + empty, matching add_note and redact_note. + ai_client: Optional marker for an outside AI editor (for example, + MCP). No environment fallback is applied. Returns: dict: Updated note in ``event``. @@ -247,11 +257,17 @@ def edit_note( Malformed event IDs return HTTP 400; transient transaction failures return HTTP 503 and can be retried. """ + body: dict[str, Any] = {"content": content} + session_id = ai_session_id or os.environ.get("LC_AI_SESSION_ID") + if session_id: + body["ai_session_id"] = session_id + if ai_client: + body["ai_client"] = ai_client return self._request( "PUT", f"cases/{case_number}/notes/{quote(event_id, safe='')}/content", query_params={"oid": self.oid}, - body={"content": content}, + body=body, ) def redact_note( diff --git a/tests/unit/test_case_note_mutations.py b/tests/unit/test_case_note_mutations.py index 7eef6d39..a2ac19fc 100644 --- a/tests/unit/test_case_note_mutations.py +++ b/tests/unit/test_case_note_mutations.py @@ -240,3 +240,52 @@ def test_cli_redaction_inherits_current_actor_session(org, monkeypatch): "ai_session_id": "current-cli-session", } assert json.loads(result.output)["images_pending_deletion"] == 1 + + +@pytest.mark.parametrize("markers,env_session,expected", [ + ({"ai_session_id": "current-session"}, None, {"ai_session_id": "current-session"}), + ({"ai_client": "outside-ai-client"}, None, {"ai_client": "outside-ai-client"}), + ({"ai_session_id": "current-session", "ai_client": "ai-agent"}, None, + {"ai_session_id": "current-session", "ai_client": "ai-agent"}), + ({}, "env-session", {"ai_session_id": "env-session"}), + ({"ai_session_id": "explicit-session"}, "env-session", {"ai_session_id": "explicit-session"}), + ({"ai_session_id": "", "ai_client": "ai-agent"}, "env-session", + {"ai_session_id": "env-session", "ai_client": "ai-agent"}), +]) +def test_edit_actor_markers(org, monkeypatch, markers, env_session, expected): + if env_session is not None: + monkeypatch.setenv("LC_AI_SESSION_ID", env_session) + response = {"event": {"event_id": "evt-1"}} + org.client.request.return_value = response + assert Cases(org).edit_note(42, "evt-1", "replacement", **markers) == response + args, kwargs = org.client.request.call_args + assert args == ("PUT", "api/v1/cases/42/notes/evt-1/content") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert kwargs["content_type"] == "application/json" + assert json.loads(kwargs["raw_body"]) == {"content": "replacement", **expected} + + +@pytest.mark.parametrize("markers", [{}, {"ai_session_id": "", "ai_client": ""}]) +def test_edit_without_actor_markers_keeps_content_only(org, markers): + Cases(org).edit_note(42, "evt-1", "replacement", **markers) + assert json.loads(org.client.request.call_args.kwargs["raw_body"]) == { + "content": "replacement", + } + + +def test_cli_edit_inherits_current_actor_session(org, monkeypatch): + monkeypatch.setenv("LC_AI_SESSION_ID", "current-editor-session") + org.client.request.return_value = {"event": {"event_id": "evt-1"}} + with patch("limacharlie.commands.case_cmd.Client", return_value=org.client), \ + patch("limacharlie.commands.case_cmd.Organization", return_value=org): + result = invoke("edit-note", ["--content", " replacement\n"]) + assert result.exit_code == 0, result.output + args, kwargs = org.client.request.call_args + assert args == ("PUT", "api/v1/cases/42/notes/evt-1/content") + assert kwargs["query_params"] == {"oid": "test-oid"} + assert kwargs["alt_root"] == "https://cases-staging.limacharlie.io" + assert json.loads(kwargs["raw_body"]) == { + "content": "replacement", "ai_session_id": "current-editor-session", + } + assert json.loads(result.output) == {"event": {"event_id": "evt-1"}}