From e08e03b500b4062d91c9c5c0a6f19789d7ed6a54 Mon Sep 17 00:00:00 2001 From: Dcha Agent Date: Sat, 28 Mar 2026 15:57:58 +0800 Subject: [PATCH 1/2] Externalize secret scrub patterns and expand output redaction coverage Move secret scrub regexes out of session_runtime.py into a properties-style resource file using name=regex entries and derive replacement placeholders automatically from the pattern name. Expand output-side redaction coverage for common secret-looking content, including PEM-like blocks, certificate blocks, long hex-like blobs, and long base64-like blobs, without decoding the content. Add regression tests for the externalized scrub patterns and the new redaction cases while preserving existing token redaction behavior. --- .../secret_scrub_patterns.properties | 12 ++++++ src/coding_agent_telegram/session_runtime.py | 42 +++++++++++++------ tests/test_session_runtime_security.py | 37 ++++++++++++++++ 3 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 src/coding_agent_telegram/resources/secret_scrub_patterns.properties diff --git a/src/coding_agent_telegram/resources/secret_scrub_patterns.properties b/src/coding_agent_telegram/resources/secret_scrub_patterns.properties new file mode 100644 index 0000000..e264b3e --- /dev/null +++ b/src/coding_agent_telegram/resources/secret_scrub_patterns.properties @@ -0,0 +1,12 @@ +# name=regex ; replacement is derived automatically as +telegram-token=\b\d{9,10}:[A-Za-z0-9_-]{30,}\b +github-token=\bgh[pousr]_[A-Za-z0-9]{36,}\b +openai-key=\bsk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}\b +gitlab-token=\bglpat-[A-Za-z0-9\-_]{20,}\b +slack-token=\bxox[baprs]-[A-Za-z0-9\-]{10,}\b +gcp-api-key=\bAIza[A-Za-z0-9\-_]{35}\b +aws-access-key=\bAKIA[A-Z0-9]{16}\b +crt-like-text=(?s)-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE----- +pem-like-text=(?s)-----BEGIN [A-Z0-9_ -]+-----.*?-----END [A-Z0-9_ -]+----- +hex-like-text=\b(?:[A-Fa-f0-9]{32,})\b +base64-like-text=\b(?:[A-Za-z0-9+/]{48,}={0,2})\b diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index 8b989e7..8a10eac 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -3,6 +3,7 @@ import asyncio import hashlib import html +import importlib.resources import logging import re from pathlib import Path @@ -56,22 +57,39 @@ "On macOS this often means a hidden permission dialog is waiting for input on the machine running the bot." ) -# Patterns for secrets that the agent might echo back from files it has read. -# Matches are replaced with a placeholder before sending to Telegram. -_SECRET_SCRUB_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( - (re.compile(r"\d{9,10}:[A-Za-z0-9_-]{35}"), ""), - (re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), ""), - (re.compile(r"sk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}"), ""), - (re.compile(r"glpat-[A-Za-z0-9\-_]{20,}"), ""), - (re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"), ""), - (re.compile(r"AIza[A-Za-z0-9\-_]{35}"), ""), - (re.compile(r"AKIA[A-Z0-9]{16}"), ""), -) - # Matches absolute filesystem paths (Unix and Windows styles) in error messages. _ABSOLUTE_PATH_RE = re.compile(r"(?:^|(?<=\s)|(?<=[\"'(]))((?:/[^\s\"',;)]+)+|[A-Za-z]:\\[^\s\"',;)]+)") +def _load_secret_scrub_patterns() -> tuple[tuple[re.Pattern[str], str], ...]: + resource = importlib.resources.files("coding_agent_telegram").joinpath("resources/secret_scrub_patterns.properties") + compiled: list[tuple[re.Pattern[str], str]] = [] + try: + raw_text = resource.read_text(encoding="utf-8") + except OSError: + logger.exception("Failed to load secret scrub patterns from %s.", resource) + return () + for raw_line in raw_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, pattern_text = line.split("=", 1) + name = name.strip() + pattern_text = pattern_text.strip() + if not name or not pattern_text: + continue + try: + compiled.append((re.compile(pattern_text), f"<{name}>")) + except re.error: + logger.exception("Invalid secret scrub regex for pattern '%s'.", name) + return tuple(compiled) + + +# Patterns for secrets that the agent might echo back from files it has read. +# Matches are replaced with a placeholder before sending to Telegram. +_SECRET_SCRUB_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = _load_secret_scrub_patterns() + + def _scrub_secrets(text: str) -> str: """Replace known secret patterns in *text* with redaction placeholders.""" for pattern, replacement in _SECRET_SCRUB_PATTERNS: diff --git a/tests/test_session_runtime_security.py b/tests/test_session_runtime_security.py index f9c940b..527d611 100644 --- a/tests/test_session_runtime_security.py +++ b/tests/test_session_runtime_security.py @@ -54,6 +54,43 @@ def test_scrub_secrets_handles_multiple_secrets_in_same_text(): assert "ghs_ABCDE" not in result +def test_scrub_secrets_redacts_pem_like_text(): + text = ( + "cert:\n" + "-----BEGIN PRIVATE KEY-----\n" + "abc123ABC123abc123ABC123abc123ABC123abc123ABC123\n" + "-----END PRIVATE KEY-----" + ) + result = _scrub_secrets(text) + assert "" in result + assert "BEGIN PRIVATE KEY" not in result + + +def test_scrub_secrets_redacts_certificate_block(): + text = ( + "-----BEGIN CERTIFICATE-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtesttesttesttest\n" + "-----END CERTIFICATE-----" + ) + result = _scrub_secrets(text) + assert "" in result + assert "BEGIN CERTIFICATE" not in result + + +def test_scrub_secrets_redacts_hex_like_text(): + text = "fingerprint deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + result = _scrub_secrets(text) + assert "" in result + assert "deadbeefdeadbeef" not in result + + +def test_scrub_secrets_redacts_base64_like_text(): + text = "blob QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo0MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=" + result = _scrub_secrets(text) + assert "" in result + assert "QUJDREVGR0hJ" not in result + + # --------------------------------------------------------------------------- # _sanitize_agent_error # --------------------------------------------------------------------------- From 9d99e6cd03e8c1e3e12ca1e82ee7669f17d205a3 Mon Sep 17 00:00:00 2001 From: Dcha Agent Date: Sat, 28 Mar 2026 18:05:54 +0800 Subject: [PATCH 2/2] Refine queued question handling and strengthen output secret scrubbing Refactor base.py Externalize secret scrub patterns in a properties file, widen coverage for modern provider keys and common sensitive OS/auth material, and make the scrubber configurable through ENABLE_SECRET_SCRUB_FILTER with a strongly recommended default of true. Update config and regression tests for the new queue flow, queue decision callbacks, secret scrub patterns, and the secret scrub feature flag. --- src/coding_agent_telegram/bot.py | 1 + src/coding_agent_telegram/command_router.py | 2 + src/coding_agent_telegram/config.py | 2 + .../resources/.env.example | 4 + .../secret_scrub_patterns.properties | 9 +- src/coding_agent_telegram/router/base.py | 184 +----------- .../router/message_commands.py | 2 +- .../router/queue_processing.py | 273 ++++++++++++++++++ .../router/session_status_commands.py | 37 +++ src/coding_agent_telegram/session_runtime.py | 3 +- tests/test_command_router.py | 68 +++-- tests/test_config.py | 15 +- tests/test_session_runtime_security.py | 54 ++++ 13 files changed, 437 insertions(+), 217 deletions(-) create mode 100644 src/coding_agent_telegram/router/queue_processing.py diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index b4c76cf..6f0ded2 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -100,6 +100,7 @@ def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: se app.add_handler(CommandHandler("commit", router.handle_commit, filters=allowed_private)) app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private)) app.add_handler(CallbackQueryHandler(router.handle_provider_callback, pattern=r"^provider:set:(codex|copilot)$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:(local|origin):", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False)) diff --git a/src/coding_agent_telegram/command_router.py b/src/coding_agent_telegram/command_router.py index 27b4e04..29ba4eb 100644 --- a/src/coding_agent_telegram/command_router.py +++ b/src/coding_agent_telegram/command_router.py @@ -4,6 +4,7 @@ from coding_agent_telegram.router.git_commands import GitCommandMixin from coding_agent_telegram.router.message_commands import MessageCommandMixin from coding_agent_telegram.router.project_commands import ProjectCommandMixin +from coding_agent_telegram.router.queue_processing import QueueProcessingMixin from coding_agent_telegram.router.session_commands import SessionCommandMixin from coding_agent_telegram.router.switch_commands import SwitchCommandMixin @@ -13,6 +14,7 @@ class CommandRouter( GitCommandMixin, SwitchCommandMixin, SessionCommandMixin, + QueueProcessingMixin, MessageCommandMixin, CommandRouterBase, ): diff --git a/src/coding_agent_telegram/config.py b/src/coding_agent_telegram/config.py index 502e89f..8a9790d 100644 --- a/src/coding_agent_telegram/config.py +++ b/src/coding_agent_telegram/config.py @@ -45,6 +45,7 @@ class AppConfig: snapshot_text_file_max_bytes: int max_telegram_message_length: int enable_sensitive_diff_filter: bool + enable_secret_scrub_filter: bool default_agent_provider: str agent_hard_timeout_seconds: int app_internal_root: Path @@ -173,6 +174,7 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig: os.getenv("MAX_TELEGRAM_MESSAGE_LENGTH", str(DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH)) ), enable_sensitive_diff_filter=_parse_bool(os.getenv("ENABLE_SENSITIVE_DIFF_FILTER", "true"), default=True), + enable_secret_scrub_filter=_parse_bool(os.getenv("ENABLE_SECRET_SCRUB_FILTER", "true"), default=True), default_agent_provider=provider, agent_hard_timeout_seconds=int( os.getenv("AGENT_HARD_TIMEOUT_SECONDS", str(DEFAULT_AGENT_HARD_TIMEOUT_SECONDS)) diff --git a/src/coding_agent_telegram/resources/.env.example b/src/coding_agent_telegram/resources/.env.example index 0737b8a..eb73b72 100644 --- a/src/coding_agent_telegram/resources/.env.example +++ b/src/coding_agent_telegram/resources/.env.example @@ -80,6 +80,10 @@ ENABLE_GROUP_CHATS=false # If true, hide diffs for sensitive-looking file paths. ENABLE_SENSITIVE_DIFF_FILTER=true +# If true, redact recognized secrets and secret-like blobs from assistant output before sending them to Telegram. +# Strongly recommended: keep this set to true. +ENABLE_SECRET_SCRUB_FILTER=true + # Default agent provider for new sessions: codex or copilot. DEFAULT_AGENT_PROVIDER=codex diff --git a/src/coding_agent_telegram/resources/secret_scrub_patterns.properties b/src/coding_agent_telegram/resources/secret_scrub_patterns.properties index e264b3e..a8b54c6 100644 --- a/src/coding_agent_telegram/resources/secret_scrub_patterns.properties +++ b/src/coding_agent_telegram/resources/secret_scrub_patterns.properties @@ -1,11 +1,18 @@ # name=regex ; replacement is derived automatically as telegram-token=\b\d{9,10}:[A-Za-z0-9_-]{30,}\b github-token=\bgh[pousr]_[A-Za-z0-9]{36,}\b -openai-key=\bsk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}\b +openai-project-key=\bsk-proj-[A-Za-z0-9_-]{20,}\b +openai-key=\bsk-(?!proj-)(?!ant-)[A-Za-z0-9_-]{20,}\b +anthropic-key=\bsk-ant-[A-Za-z0-9_-]{20,}\b gitlab-token=\bglpat-[A-Za-z0-9\-_]{20,}\b slack-token=\bxox[baprs]-[A-Za-z0-9\-]{10,}\b +stripe-secret-key=\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b gcp-api-key=\bAIza[A-Za-z0-9\-_]{35}\b aws-access-key=\bAKIA[A-Z0-9]{16}\b +jwt-like-token=\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b +ssh-public-key=(?m)^(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521)) [A-Za-z0-9+/=]+(?: [^\r\n]+)?$ +passwd-like-line=(?m)^[a-z_][a-z0-9_-]{0,31}:[x*]?:\d+:\d+:[^:\r\n]*:(?:/[^:\r\n]*)?:(?:/[^:\r\n]+|[A-Za-z0-9_./-]+)$ +shadow-like-line=(?m)^[a-z_][a-z0-9_-]{0,31}:\$[^:\r\n]{10,}:[0-9:]{5,}.*$ crt-like-text=(?s)-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE----- pem-like-text=(?s)-----BEGIN [A-Z0-9_ -]+-----.*?-----END [A-Z0-9_ -]+----- hex-like-text=\b(?:[A-Fa-f0-9]{32,})\b diff --git a/src/coding_agent_telegram/router/base.py b/src/coding_agent_telegram/router/base.py index b66870a..d9371fa 100644 --- a/src/coding_agent_telegram/router/base.py +++ b/src/coding_agent_telegram/router/base.py @@ -11,7 +11,6 @@ from dataclasses import dataclass from functools import wraps from pathlib import Path -from types import SimpleNamespace from typing import Awaitable, Callable, Optional, Tuple from telegram import Update @@ -32,7 +31,6 @@ TYPING_REFRESH_TIMEOUT_SECONDS = 4 ACTIVE_SESSION_REQUIRED_MESSAGE = "No active session.\nPlease run /project and /new first." PROGRESS_PREVIEW_MAX_CHARS = 600 -QUEUED_QUESTIONS_DIR = "queued_questions" def require_allowed_chat(*, answer_callback: bool = False): @@ -125,6 +123,7 @@ def __init__(self, deps: RouterDeps) -> None: self._workspace_locks: dict[str, asyncio.Lock] = {} self._chat_message_queue_files: dict[int, deque[Path]] = {} self._chat_processing_queue_files: dict[int, Path] = {} + self._chat_pending_queue_decisions: dict[int, tuple[Path, list[str]]] = {} self._chat_next_queue_file_index: dict[int, int] = {} self._chat_message_queue_draining: set[int] = set() self._last_run_results: dict[int, object] = {} @@ -199,187 +198,6 @@ def _is_project_busy(self, chat_id: int) -> bool: has_running_process = getattr(self.deps.agent_runner, "has_running_process", None) return bool(has_running_process is not None and has_running_process(project_path)) - def _queue_dir(self, chat_id: int) -> Path: - queue_dir = self.deps.cfg.app_internal_root / QUEUED_QUESTIONS_DIR / str(chat_id) - queue_dir.mkdir(parents=True, exist_ok=True) - return queue_dir - - def _queue_lock_path(self, queue_file: Path) -> Path: - return queue_file.with_suffix(queue_file.suffix + ".lock") - - def _sanitize_queue_session_id(self, session_id: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", session_id.strip()) - return cleaned or "session" - - def _next_queue_file_path(self, chat_id: int) -> Path: - queue_dir = self._queue_dir(chat_id) - if chat_id not in self._chat_message_queue_files and chat_id not in self._chat_processing_queue_files: - next_index = 0 - else: - next_index = self._chat_next_queue_file_index.get(chat_id, -1) + 1 - self._chat_next_queue_file_index[chat_id] = next_index - chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) - session_id = self._sanitize_queue_session_id(str(chat_state.get("active_session_id") or "session")) - return queue_dir / f"{session_id}-queue-{next_index}.txt" - - def _read_queue_questions(self, queue_file: Path) -> list[str]: - if not queue_file.exists(): - return [] - raw = queue_file.read_text(encoding="utf-8") - pattern = re.compile(r"^\[Question (\d+)\]\n(.*?)\n\[End Question \1\]\s*$", re.MULTILINE | re.DOTALL) - return [match.group(2).strip() for match in pattern.finditer(raw) if match.group(2).strip()] - - def _append_question_to_queue_file(self, queue_file: Path, user_message: str) -> int: - questions = self._read_queue_questions(queue_file) - next_number = len(questions) + 1 - with queue_file.open("a", encoding="utf-8") as fh: - if queue_file.stat().st_size > 0: - fh.write("\n") - fh.write(f"[Question {next_number}]\n{user_message.strip()}\n[End Question {next_number}]\n") - return next_number - - def _enqueue_chat_message(self, chat_id: int, user_message: str) -> tuple[Path, int]: - queue = self._chat_message_queue_files.setdefault(chat_id, deque()) - queue_file = queue[-1] if queue else self._next_queue_file_path(chat_id) - if not queue: - queue.append(queue_file) - question_number = self._append_question_to_queue_file(queue_file, user_message) - return queue_file, question_number - - def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[str]]: - queue = self._chat_message_queue_files.get(chat_id) - if not queue: - return None, [] - queue_file = queue.popleft() - questions = self._read_queue_questions(queue_file) - if not questions: - if not queue: - self._chat_message_queue_files.pop(chat_id, None) - return None, [] - if not queue: - self._chat_message_queue_files.pop(chat_id, None) - return queue_file, questions - - def _queued_file_prompt(self, queue_file: Path) -> str: - return ( - "Queued-question handoff. Do not answer this handoff message itself.\n\n" - "The real queued user questions are stored in this file:\n" - f"{queue_file.resolve()}\n\n" - "Read the file first, then answer the questions from the file in order.\n" - "The file uses this format:\n" - "[Question 1]\n" - "...\n" - "[End Question 1]\n\n" - "Do not answer about the file path itself unless one of the queued questions asks about it." - ) - - def _preview_queued_message(self, message: str, *, max_chars: int = 100) -> str: - stripped = " ".join(message.split()) - if len(stripped) <= max_chars: - return stripped - if max_chars <= 3: - return stripped[:max_chars] - return f"{stripped[: max_chars - 3]}..." - - def _queued_batch_notice(self, queued_messages: list[str]) -> str: - lines = ["Working on queued questions:"] - for index, message in enumerate(queued_messages, start=1): - lines.append(f"{index}. {self._preview_queued_message(message)}") - return "\n".join(lines) - - def _run_result_was_aborted(self, result: object) -> bool: - error_message = getattr(result, "error_message", None) - return isinstance(error_message, str) and error_message.strip() == "Agent run aborted by /abort." - - def _has_pending_queue_files(self, chat_id: int) -> bool: - queue = self._chat_message_queue_files.get(chat_id) - return bool(queue) - - async def _prompt_continue_queued_questions(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: - if not hasattr(context.bot, "send_message"): - return - from telegram import InlineKeyboardButton, InlineKeyboardMarkup - - await context.bot.send_message( - chat_id=chat_id, - text="The previous run was aborted. Do you want to continue processing the pending queued questions?", - reply_markup=InlineKeyboardMarkup( - [[ - InlineKeyboardButton("Yes", callback_data="queuecontinue:yes"), - InlineKeyboardButton("No", callback_data="queuecontinue:no"), - ]] - ), - ) - - def _clear_chat_message_queue(self, chat_id: int) -> None: - queue = self._chat_message_queue_files.pop(chat_id, deque()) - for queue_file in queue: - queue_file.unlink(missing_ok=True) - self._queue_lock_path(queue_file).unlink(missing_ok=True) - processing_file = self._chat_processing_queue_files.pop(chat_id, None) - if processing_file is not None: - processing_file.unlink(missing_ok=True) - self._queue_lock_path(processing_file).unlink(missing_ok=True) - self._chat_next_queue_file_index.pop(chat_id, None) - - async def _drain_chat_message_queue(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: - if chat_id in self._chat_message_queue_draining: - return - self._chat_message_queue_draining.add(chat_id) - try: - while True: - if self._is_project_busy(chat_id): - return - last_result = self._last_run_results.pop(chat_id, None) - if self._run_result_was_aborted(last_result) and self._has_pending_queue_files(chat_id): - processing_file = self._chat_processing_queue_files.get(chat_id) - if processing_file is not None: - processing_file.unlink(missing_ok=True) - self._queue_lock_path(processing_file).unlink(missing_ok=True) - self._chat_processing_queue_files.pop(chat_id, None) - await self._prompt_continue_queued_questions(chat_id, context) - return - processing_file = self._chat_processing_queue_files.get(chat_id) - if processing_file is not None: - processing_file.unlink(missing_ok=True) - self._queue_lock_path(processing_file).unlink(missing_ok=True) - self._chat_processing_queue_files.pop(chat_id, None) - queue_file, queued_messages = self._dequeue_chat_message_file(chat_id) - if queue_file is None or not queued_messages: - if chat_id not in self._chat_processing_queue_files and chat_id not in self._chat_message_queue_files: - self._chat_next_queue_file_index.pop(chat_id, None) - return - self._chat_processing_queue_files[chat_id] = queue_file - self._queue_lock_path(queue_file).write_text("", encoding="utf-8") - queued_notice = self._queued_batch_notice(queued_messages) - queued_update = SimpleNamespace( - effective_chat=SimpleNamespace(id=chat_id, type="private"), - message=SimpleNamespace(text=queued_notice, photo=None, caption=None), - ) - await send_text(queued_update, context, queued_notice) - combined_message = self._queued_file_prompt(queue_file) - queued_update = SimpleNamespace( - effective_chat=SimpleNamespace(id=chat_id, type="private"), - message=SimpleNamespace(text=combined_message, photo=None, caption=None), - ) - self.deps.store.set_pending_action( - self.deps.bot_id, - chat_id, - { - "kind": "message", - "user_message": combined_message, - }, - ) - continued = await self._continue_pending_action(queued_update, context) - if not continued: - self._queue_lock_path(queue_file).unlink(missing_ok=True) - self._chat_processing_queue_files.pop(chat_id, None) - queue = self._chat_message_queue_files.setdefault(chat_id, deque()) - queue.appendleft(queue_file) - return - finally: - self._chat_message_queue_draining.discard(chat_id) - async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: chat = update.effective_chat if chat is None: diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py index da6312e..618ceda 100644 --- a/src/coding_agent_telegram/router/message_commands.py +++ b/src/coding_agent_telegram/router/message_commands.py @@ -15,7 +15,7 @@ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYP return user_message = update.message.text chat_id = update.effective_chat.id - if self._is_project_busy(chat_id): + if self._is_project_busy(chat_id) or self._has_pending_queue_decision(chat_id): _queue_file, question_number = self._enqueue_chat_message(chat_id, user_message) await send_text( update, diff --git a/src/coding_agent_telegram/router/queue_processing.py b/src/coding_agent_telegram/router/queue_processing.py new file mode 100644 index 0000000..539bd32 --- /dev/null +++ b/src/coding_agent_telegram/router/queue_processing.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import re +from collections import deque +from pathlib import Path +from types import SimpleNamespace + +from telegram.ext import ContextTypes + +from coding_agent_telegram.telegram_sender import send_text + + +QUEUED_QUESTIONS_DIR = "queued_questions" + + +class QueueProcessingMixin: + def _queue_dir(self, chat_id: int) -> Path: + queue_dir = self.deps.cfg.app_internal_root / QUEUED_QUESTIONS_DIR / str(chat_id) + queue_dir.mkdir(parents=True, exist_ok=True) + return queue_dir + + def _queue_lock_path(self, queue_file: Path) -> Path: + return queue_file.with_suffix(queue_file.suffix + ".lock") + + def _sanitize_queue_session_id(self, session_id: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", session_id.strip()) + return cleaned or "session" + + def _next_queue_file_path(self, chat_id: int) -> Path: + queue_dir = self._queue_dir(chat_id) + if chat_id not in self._chat_message_queue_files and chat_id not in self._chat_processing_queue_files: + next_index = 0 + else: + next_index = self._chat_next_queue_file_index.get(chat_id, -1) + 1 + self._chat_next_queue_file_index[chat_id] = next_index + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + session_id = self._sanitize_queue_session_id(str(chat_state.get("active_session_id") or "session")) + return queue_dir / f"{session_id}-queue-{next_index}.txt" + + def _read_queue_questions(self, queue_file: Path) -> list[str]: + if not queue_file.exists(): + return [] + raw = queue_file.read_text(encoding="utf-8") + pattern = re.compile(r"^\[Question (\d+)\]\n(.*?)\n\[End Question \1\]\s*$", re.MULTILINE | re.DOTALL) + return [match.group(2).strip() for match in pattern.finditer(raw) if match.group(2).strip()] + + def _append_question_to_queue_file(self, queue_file: Path, user_message: str) -> int: + questions = self._read_queue_questions(queue_file) + next_number = len(questions) + 1 + with queue_file.open("a", encoding="utf-8") as fh: + if queue_file.stat().st_size > 0: + fh.write("\n") + fh.write(f"[Question {next_number}]\n{user_message.strip()}\n[End Question {next_number}]\n") + return next_number + + def _write_queue_questions(self, queue_file: Path, questions: list[str]) -> None: + with queue_file.open("w", encoding="utf-8") as fh: + for index, question in enumerate(questions, start=1): + if index > 1: + fh.write("\n") + fh.write(f"[Question {index}]\n{question.strip()}\n[End Question {index}]\n") + + def _enqueue_chat_message(self, chat_id: int, user_message: str) -> tuple[Path, int]: + queue = self._chat_message_queue_files.setdefault(chat_id, deque()) + queue_file = queue[-1] if queue else self._next_queue_file_path(chat_id) + if not queue: + queue.append(queue_file) + question_number = self._append_question_to_queue_file(queue_file, user_message) + return queue_file, question_number + + def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[str]]: + queue = self._chat_message_queue_files.get(chat_id) + if not queue: + return None, [] + queue_file = queue.popleft() + questions = self._read_queue_questions(queue_file) + if not questions: + if not queue: + self._chat_message_queue_files.pop(chat_id, None) + return None, [] + if not queue: + self._chat_message_queue_files.pop(chat_id, None) + return queue_file, questions + + def _queued_batch_prompt(self, queued_messages: list[str]) -> str: + lines = ["Answer the following queued user questions in order."] + for index, message in enumerate(queued_messages, start=1): + lines.extend(["", f"[Question {index}]", message.strip(), f"[End Question {index}]"]) + return "\n".join(lines) + + def _preview_queued_message(self, message: str, *, max_chars: int = 100) -> str: + stripped = " ".join(message.split()) + if len(stripped) <= max_chars: + return stripped + if max_chars <= 3: + return stripped[:max_chars] + return f"{stripped[: max_chars - 3]}..." + + def _queued_batch_notice(self, queued_messages: list[str]) -> str: + lines = ["Working on queued questions:"] + for index, message in enumerate(queued_messages, start=1): + lines.append(f"{index}. {self._preview_queued_message(message)}") + return "\n".join(lines) + + def _has_pending_queue_decision(self, chat_id: int) -> bool: + return chat_id in self._chat_pending_queue_decisions + + def _run_result_was_aborted(self, result: object) -> bool: + error_message = getattr(result, "error_message", None) + return isinstance(error_message, str) and error_message.strip() == "Agent run aborted by /abort." + + def _has_pending_queue_files(self, chat_id: int) -> bool: + queue = self._chat_message_queue_files.get(chat_id) + return bool(queue) + + async def _prompt_continue_queued_questions(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: + if not hasattr(context.bot, "send_message"): + return + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + + await context.bot.send_message( + chat_id=chat_id, + text="The previous run was aborted. Do you want to continue processing the pending queued questions?", + reply_markup=InlineKeyboardMarkup( + [[ + InlineKeyboardButton("Yes", callback_data="queuecontinue:yes"), + InlineKeyboardButton("No", callback_data="queuecontinue:no"), + ]] + ), + ) + + async def _prompt_queue_batch_decision( + self, + chat_id: int, + context: ContextTypes.DEFAULT_TYPE, + queued_messages: list[str], + ) -> None: + if not hasattr(context.bot, "send_message"): + return + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + + lines = [ + "Multiple queued questions are ready.", + "", + "Here are the queued questions:", + ] + for index, message in enumerate(queued_messages, start=1): + lines.append(f"Q{index}: {self._preview_queued_message(message)}") + lines.extend( + [ + "", + "Choose how to process them.", + "Group questions only when they are closely related and each question is reasonably short.", + "Combining too many questions can make the prompt too large or reduce the agent's focus.", + ] + ) + await context.bot.send_message( + chat_id=chat_id, + text="\n".join(lines), + reply_markup=InlineKeyboardMarkup( + [[ + InlineKeyboardButton("Group the questions", callback_data="queuebatch:group"), + InlineKeyboardButton("Process one by one", callback_data="queuebatch:single"), + ]] + ), + ) + + def _clear_chat_message_queue(self, chat_id: int) -> None: + queue = self._chat_message_queue_files.pop(chat_id, deque()) + for queue_file in queue: + queue_file.unlink(missing_ok=True) + self._queue_lock_path(queue_file).unlink(missing_ok=True) + processing_file = self._chat_processing_queue_files.pop(chat_id, None) + if processing_file is not None: + processing_file.unlink(missing_ok=True) + self._queue_lock_path(processing_file).unlink(missing_ok=True) + pending = self._chat_pending_queue_decisions.pop(chat_id, None) + if pending is not None: + pending[0].unlink(missing_ok=True) + self._queue_lock_path(pending[0]).unlink(missing_ok=True) + self._chat_next_queue_file_index.pop(chat_id, None) + + async def _dispatch_queued_questions( + self, + chat_id: int, + context: ContextTypes.DEFAULT_TYPE, + *, + queue_file: Path, + queued_messages: list[str], + grouped: bool, + ) -> bool: + self._chat_processing_queue_files[chat_id] = queue_file + self._queue_lock_path(queue_file).write_text("", encoding="utf-8") + current_batch = queued_messages if grouped or len(queued_messages) <= 1 else queued_messages[:1] + queued_notice = self._queued_batch_notice(current_batch) + queued_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=chat_id, type="private"), + message=SimpleNamespace(text=queued_notice, photo=None, caption=None), + ) + await send_text(queued_update, context, queued_notice) + if grouped: + user_message = self._queued_batch_prompt(queued_messages) + else: + user_message = queued_messages[0] + queued_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=chat_id, type="private"), + message=SimpleNamespace(text=user_message, photo=None, caption=None), + ) + self.deps.store.set_pending_action( + self.deps.bot_id, + chat_id, + { + "kind": "message", + "user_message": user_message, + }, + ) + continued = await self._continue_pending_action(queued_update, context) + if not continued: + self._queue_lock_path(queue_file).unlink(missing_ok=True) + self._chat_processing_queue_files.pop(chat_id, None) + queue = self._chat_message_queue_files.setdefault(chat_id, deque()) + queue.appendleft(queue_file) + return False + if not grouped and len(queued_messages) > 1: + remaining_queue_file = self._next_queue_file_path(chat_id) + self._write_queue_questions(remaining_queue_file, queued_messages[1:]) + queue = self._chat_message_queue_files.setdefault(chat_id, deque()) + queue.appendleft(remaining_queue_file) + return True + + async def _drain_chat_message_queue(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: + if chat_id in self._chat_message_queue_draining: + return + self._chat_message_queue_draining.add(chat_id) + try: + while True: + if self._is_project_busy(chat_id): + return + last_result = self._last_run_results.pop(chat_id, None) + if self._run_result_was_aborted(last_result) and self._has_pending_queue_files(chat_id): + processing_file = self._chat_processing_queue_files.get(chat_id) + if processing_file is not None: + processing_file.unlink(missing_ok=True) + self._queue_lock_path(processing_file).unlink(missing_ok=True) + self._chat_processing_queue_files.pop(chat_id, None) + await self._prompt_continue_queued_questions(chat_id, context) + return + processing_file = self._chat_processing_queue_files.get(chat_id) + if processing_file is not None: + processing_file.unlink(missing_ok=True) + self._queue_lock_path(processing_file).unlink(missing_ok=True) + self._chat_processing_queue_files.pop(chat_id, None) + queue_file, queued_messages = self._dequeue_chat_message_file(chat_id) + if queue_file is None or not queued_messages: + if chat_id not in self._chat_processing_queue_files and chat_id not in self._chat_message_queue_files: + self._chat_next_queue_file_index.pop(chat_id, None) + return + if len(queued_messages) == 1: + continued = await self._dispatch_queued_questions( + chat_id, + context, + queue_file=queue_file, + queued_messages=queued_messages, + grouped=False, + ) + if not continued: + return + continue + self._chat_pending_queue_decisions[chat_id] = (queue_file, queued_messages) + await self._prompt_queue_batch_decision(chat_id, context, queued_messages) + return + finally: + self._chat_message_queue_draining.discard(chat_id) diff --git a/src/coding_agent_telegram/router/session_status_commands.py b/src/coding_agent_telegram/router/session_status_commands.py index abea110..391b238 100644 --- a/src/coding_agent_telegram/router/session_status_commands.py +++ b/src/coding_agent_telegram/router/session_status_commands.py @@ -77,3 +77,40 @@ async def handle_queue_continue_callback(self, update: Update, context: ContextT if decision == "no": self._clear_chat_message_queue(chat_id) await query.edit_message_text("Pending queued questions were discarded.") + + @require_allowed_chat(answer_callback=True) + async def handle_queue_batch_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None or query.data is None: + return + + await query.answer() + _, _, decision = query.data.partition("queuebatch:") + chat_id = update.effective_chat.id + pending = self._chat_pending_queue_decisions.pop(chat_id, None) + if pending is None: + await query.edit_message_text("No queued batch is waiting for a decision.") + return + + queue_file, queued_messages = pending + if decision == "group": + await query.edit_message_text("Processing the queued questions as one batch.") + await self._dispatch_queued_questions( + chat_id, + context, + queue_file=queue_file, + queued_messages=queued_messages, + grouped=True, + ) + await self._drain_chat_message_queue(chat_id, context) + return + if decision == "single": + await query.edit_message_text("Processing the queued questions one by one.") + await self._dispatch_queued_questions( + chat_id, + context, + queue_file=queue_file, + queued_messages=queued_messages, + grouped=False, + ) + await self._drain_chat_message_queue(chat_id, context) diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index 8a10eac..aa4dfa2 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -504,7 +504,8 @@ async def _send_assistant_chunks( *, provider: str, ) -> None: - assistant_text = _scrub_secrets(assistant_text) + if self.cfg.enable_secret_scrub_filter: + assistant_text = _scrub_secrets(assistant_text) segments = split_assistant_output(assistant_text) if not segments: return diff --git a/tests/test_command_router.py b/tests/test_command_router.py index 119d530..01007fa 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -404,6 +404,7 @@ def make_config(tmp_path: Path) -> AppConfig: snapshot_text_file_max_bytes=200000, max_telegram_message_length=3000, enable_sensitive_diff_filter=True, + enable_secret_scrub_filter=True, default_agent_provider="codex", agent_hard_timeout_seconds=0, app_internal_root=tmp_path / ".coding-agent-telegram", @@ -2693,22 +2694,14 @@ async def exercise(): assert len(runner.resume_calls) == 2 assert runner.resume_calls[0]["user_message"] == "first question" - assert "Queued-question handoff. Do not answer this handoff message itself." in runner.resume_calls[1]["user_message"] - queue_file_path = next( - Path(line.strip()) - for line in runner.resume_calls[1]["user_message"].splitlines() - if line.strip().startswith("/") - ) - assert queue_file_path.is_absolute() - assert queue_file_path.name == "sess_queue-queue-0.txt" - assert queue_file_path.exists() is False + assert runner.resume_calls[1]["user_message"] == "second question" assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages) assert any("1. second question" in message for _, message, _, _ in bot.messages) asyncio.run(exercise()) -def test_new_questions_create_next_queue_file_while_previous_queue_file_is_processing(tmp_path: Path): +def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() runner = BlockingRunner() @@ -2733,35 +2726,50 @@ async def exercise(): await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot)) await router.handle_message(third_update, SimpleNamespace(args=[], bot=bot)) runner.release_next() + await first_task + + prompt_messages = [entry for entry in bot.messages if "Multiple queued questions are ready." in entry[1]] + assert len(prompt_messages) == 1 + keyboard = prompt_messages[0][3] + assert keyboard is not None + buttons = keyboard.inline_keyboard[0] + assert buttons[0].callback_data == "queuebatch:group" + assert buttons[1].callback_data == "queuebatch:single" + + answers = [] + edited = [] + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="queuebatch:group", answer=None, edit_message_text=None), + ) + + async def fake_answer(): + answers.append("answered") + + async def fake_edit(text): + edited.append(text) + + callback_update.callback_query.answer = fake_answer + callback_update.callback_query.edit_message_text = fake_edit + + callback_task = asyncio.create_task(router.handle_queue_batch_callback(callback_update, SimpleNamespace(args=[], bot=bot))) started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) assert started_second is True - await router.handle_message(fourth_update, SimpleNamespace(args=[], bot=bot)) runner.release_next() + await callback_task started_third = await asyncio.to_thread(runner.wait_started, 3, 1.0) assert started_third is True runner.release_next() - await first_task assert len(runner.resume_calls) == 3 assert runner.resume_calls[0]["user_message"] == "first question" - assert "Queued-question handoff. Do not answer this handoff message itself." in runner.resume_calls[1]["user_message"] - assert "Queued-question handoff. Do not answer this handoff message itself." in runner.resume_calls[2]["user_message"] - first_queue_file = next( - Path(line.strip()) - for line in runner.resume_calls[1]["user_message"].splitlines() - if line.strip().startswith("/") - ) - second_queue_file = next( - Path(line.strip()) - for line in runner.resume_calls[2]["user_message"].splitlines() - if line.strip().startswith("/") - ) - assert first_queue_file != second_queue_file - assert first_queue_file.name == "sess_queue-queue-0.txt" - assert second_queue_file.name == "sess_queue-queue-1.txt" - assert first_queue_file.exists() is False - assert second_queue_file.exists() is False + assert "Answer the following queued user questions in order." in runner.resume_calls[1]["user_message"] + assert "[Question 1]\ntwo\n[End Question 1]" in runner.resume_calls[1]["user_message"] + assert "[Question 2]\nthree\n[End Question 2]" in runner.resume_calls[1]["user_message"] + assert runner.resume_calls[2]["user_message"] == "four four four four four four four" + assert answers == ["answered"] + assert edited == ["Processing the queued questions as one batch."] queued_notices = [message for _, message, _, _ in bot.messages if "Working on queued questions:" in message] assert any("1. two" in message and "2. three" in message for message in queued_notices) assert any("1. four four four four four four four" in message for message in queued_notices) @@ -2880,7 +2888,7 @@ async def fake_edit(text): assert answers == ["answered"] assert edited == ["Continuing with the pending queued questions."] assert len(runner.resume_calls) == 2 - assert "Queued-question handoff. Do not answer this handoff message itself." in runner.resume_calls[1]["user_message"] + assert runner.resume_calls[1]["user_message"] == "second question" assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages) asyncio.run(exercise()) diff --git a/tests/test_config.py b/tests/test_config.py index 6eb6ddc..c528b10 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -41,6 +41,7 @@ def _isolate_env(monkeypatch, tmp_path): "SNAPSHOT_TEXT_FILE_MAX_BYTES", "MAX_TELEGRAM_MESSAGE_LENGTH", "ENABLE_SENSITIVE_DIFF_FILTER", + "ENABLE_SECRET_SCRUB_FILTER", "DEFAULT_AGENT_PROVIDER", ): monkeypatch.delenv(name, raising=False) @@ -62,7 +63,6 @@ def test_load_config_required(monkeypatch, tmp_path): monkeypatch.setenv("SNAPSHOT_TEXT_FILE_MAX_BYTES", str(DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES)) monkeypatch.setenv("MAX_TELEGRAM_MESSAGE_LENGTH", str(DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH)) monkeypatch.setenv("DEFAULT_AGENT_PROVIDER", "codex") - monkeypatch.setenv("LOG_DIR", "./logs") monkeypatch.setenv("CODEX_MODEL", "") monkeypatch.setenv("COPILOT_MODEL", "") monkeypatch.setenv("COPILOT_AUTOPILOT", "true") @@ -83,6 +83,7 @@ def test_load_config_required(monkeypatch, tmp_path): assert cfg.enable_commit_command is False assert cfg.snapshot_text_file_max_bytes == DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES assert cfg.max_telegram_message_length == DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH + assert cfg.enable_secret_scrub_filter is True assert cfg.default_agent_provider == "codex" assert cfg.log_dir.name == "logs" assert cfg.codex_model == "" @@ -129,6 +130,18 @@ def test_load_config_snapshot_limit_override(monkeypatch, tmp_path): assert cfg.snapshot_text_file_max_bytes == 4096 +def test_load_config_secret_scrub_filter_can_be_disabled(monkeypatch, tmp_path): + _isolate_env(monkeypatch, tmp_path) + monkeypatch.setenv("WORKSPACE_ROOT", "~/git") + monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a") + monkeypatch.setenv("ALLOWED_CHAT_IDS", "123") + monkeypatch.setenv("ENABLE_SECRET_SCRUB_FILTER", "false") + + cfg = load_config() + + assert cfg.enable_secret_scrub_filter is False + + def test_resolve_env_file_path_uses_explicit_env_override(monkeypatch, tmp_path): env_path = tmp_path / "custom.env" monkeypatch.setenv("CODING_AGENT_TELEGRAM_ENV_FILE", str(env_path)) diff --git a/tests/test_session_runtime_security.py b/tests/test_session_runtime_security.py index 527d611..1075694 100644 --- a/tests/test_session_runtime_security.py +++ b/tests/test_session_runtime_security.py @@ -24,6 +24,60 @@ def test_scrub_secrets_redacts_github_pat(): assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij1234" not in result +def test_scrub_secrets_redacts_openai_project_key(): + text = "OpenAI key sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij1234567890" + result = _scrub_secrets(text) + assert "" in result + assert "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ" not in result + + +def test_scrub_secrets_redacts_anthropic_key(): + text = "Anthropic key sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij1234567890" + result = _scrub_secrets(text) + assert "" in result + assert "sk-ant-api03-" not in result + + +def test_scrub_secrets_redacts_stripe_secret_key(): + text = "Stripe key sk_live_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456" + result = _scrub_secrets(text) + assert "" in result + assert "sk_live_" not in result + + +def test_scrub_secrets_redacts_jwt_like_token(): + text = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0.c2lnbmF0dXJlVmFsdWVFeGFtcGxlMTIzNDU2Nzg5MA" + result = _scrub_secrets(text) + assert "" in result + assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result + + +def test_scrub_secrets_redacts_ssh_public_key_ed25519(): + text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINPZxtCMs5sIfsMWpq7SHuqFFpBtSTmFqXWOYdf6dX4i your_email@example.com" + result = _scrub_secrets(text) + assert "" in result + assert "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5" not in result + + +def test_scrub_secrets_redacts_ssh_public_key_rsa(): + text = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCy1gU1s6n5r4qV2bFJ8XH4m2J3J4k5L6m7N8o9P0q1R2s3T4u5V6w7X8y9Z0 test@example.com" + result = _scrub_secrets(text) + assert "" in result + assert "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ" not in result + + +def test_scrub_secrets_redacts_passwd_like_line(): + text = "root:x:0:0:root:/root:/bin/bash" + result = _scrub_secrets(text) + assert result == "" + + +def test_scrub_secrets_redacts_shadow_like_line(): + text = "root:$6$rounds=656000$saltvalue$hashedsecretvaluegoeshere:19793:0:99999:7:::" + result = _scrub_secrets(text) + assert result == "" + + def test_scrub_secrets_redacts_aws_access_key(): text = "AWS key: AKIAIOSFODNN7EXAMPLE is set." result = _scrub_secrets(text)