From 0c8ba01a80dc12f3704b5e384737956076a3de7a Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Fri, 30 Oct 2026 11:09:38 +0800 Subject: [PATCH 1/2] fix(presentation): mask unlabeled provider tokens in chat activity The activity masker claimed deliberately broad coverage because a missed secret is stored for replay, but its issuer arm knew only six prefixes. Commands and reasoning summaries carrying npm, PyPI, Google, AWS session, Slack session or JWT shapes kept the raw value in the step that ChatSessionStore persists, the workspace SSE stream replays and the native steward copies into monitor_restart_basis. Compose the arm from PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE, the canonical public-safety owner the sibling scanners already consume (Refs #5136, #7635, #7636), and keep the legacy arm for the generic `sk-` form that owner does not claim. Signed-off-by: JasonBuildAI --- loopx/presentation/codex_activity.py | 8 +++++ tests/test_chat_activity_provider_tokens.py | 37 +++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/test_chat_activity_provider_tokens.py diff --git a/loopx/presentation/codex_activity.py b/loopx/presentation/codex_activity.py index 19996598e6..f60e24dda7 100644 --- a/loopx/presentation/codex_activity.py +++ b/loopx/presentation/codex_activity.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any +from ..public_safe_text import PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE + STEP_KINDS = ("reasoning", "command", "tool", "search", "file_change") STEP_STATES = ("running", "completed", "failed") COMMAND_VERBS = ("read", "search", "list", "run") @@ -56,6 +58,12 @@ re.compile(r"(?i)(--?[a-z0-9-]*(?:token|secret|password|passwd|api-?key)[a-z0-9-]*(?:\s*=\s*|\s+))" + _SHELL_VALUE), re.compile(r"(?i)(\b[a-z0-9_]*(?:token|secret|password|passwd|api_?key)[a-z0-9_]*\s*=\s*)" + _SHELL_VALUE), re.compile(r"()\b(?:sk|ghp|gho|ghs|github_pat|xox[abp])[-_][A-Za-z0-9_-]{12,}"), + # An issuer-prefixed token needs no label, so the label arms above cannot + # see it. Take the shapes from the canonical public-safety owner instead of + # keeping a second issuer list here: this list once recognized only some + # issuers and stored the rest for replay (Refs #5136, #7635, #7636). The + # narrower arm above still covers the generic `sk-` form the owner omits. + re.compile(r"()(?:" + PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE + r")", re.I), ) # Network URLs and relative paths must survive. A complete path token, rather # than a directory-name denylist, determines whether it belongs to this project. diff --git a/tests/test_chat_activity_provider_tokens.py b/tests/test_chat_activity_provider_tokens.py new file mode 100644 index 0000000000..feded08c0b --- /dev/null +++ b/tests/test_chat_activity_provider_tokens.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json + +import pytest + +from loopx.presentation.codex_activity import CodexActivitySteps + + +def _command(command: str) -> dict: + return {"type": "commandExecution", "id": "exec-1", "command": command, + "commandActions": [{"type": "unknown", "command": command}]} + + +@pytest.mark.parametrize( + "token", + [ + "np" + "m_" + "aB3dE5gH7jK9mN1pQ3sT5vX7zA9cE1gI3kM5", + "py" + "pi-" + "AgEIcHlwaS5vcmcCJDY0Zjg5YjA1LTY3", + "AI" + "za" + "SyA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q", + "AK" + "IA" + "Q3B7X9Z2N4C6V8M1", + "AS" + "IA" + "Q7D2W5Y8K3N6R9T4", + "xo" + "xs-" + "123456789012-abcdefghij", + "xo" + "xr-" + "123456789012-abcdefghij", + "ey" + "JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0." + "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + ], +) +def test_provider_tokens_in_commands_are_masked_before_replay(token: str) -> None: + steps = CodexActivitySteps() + text = f"deploy --config {token}" + command = steps.started(_command(text)) + thought = steps.completed({"type": "reasoning", "id": "r", "summary": [text]}) + + for step in (command, thought): + assert token not in json.dumps(step) + assert "***" in step["title"] From 3bcafd97dd439400219ddaec489f4836b39f264c Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Fri, 30 Oct 2026 11:18:37 +0800 Subject: [PATCH 2/2] test(presentation): keep surrounding text and reopen the store Assert the visible command stays readable around the masked value, and append the step to a real ChatSessionStore then read it back, so the regression covers the persistence the incident report describes rather than only the in-memory title. Signed-off-by: JasonBuildAI --- tests/test_chat_activity_provider_tokens.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_chat_activity_provider_tokens.py b/tests/test_chat_activity_provider_tokens.py index feded08c0b..69fd2dd8d0 100644 --- a/tests/test_chat_activity_provider_tokens.py +++ b/tests/test_chat_activity_provider_tokens.py @@ -34,4 +34,20 @@ def test_provider_tokens_in_commands_are_masked_before_replay(token: str) -> Non for step in (command, thought): assert token not in json.dumps(step) + assert "deploy --config" in step["title"] assert "***" in step["title"] + + +def test_a_masked_step_stays_clean_in_the_reopened_store(tmp_path) -> None: + from loopx.chat_store import ChatSessionStore + + token = "np" + "m_" + "aB3dE5gH7jK9mN1pQ3sT5vX7zA9cE1gI3kM5" + text = f"deploy --config {token}" + step = CodexActivitySteps().started(_command(text)) + store = ChatSessionStore(tmp_path / "store") + store.append_event("session", "turn", kind="agent.phase", payload={"step": step}) + store.flush_events("session", "turn") + + replay = ChatSessionStore(tmp_path / "store").events_after("session", "turn", None) + assert token not in json.dumps(replay) + assert [row["payload"]["step"] for row in replay] == [step]