From 64557a91dc5347f67a53260b216edd6ac085a43e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:45:16 +0000 Subject: [PATCH 01/11] Initial plan From d58812de83f00e6183d2e6fd6b57236f0f8765c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:50:26 +0000 Subject: [PATCH 02/11] feat: add client command whitelist Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 49 ++++++++++++++++++++++++++++++++- agent-sdk-client/config.toml | 6 ++++ agent-sdk-client/consumer.py | 10 +++++++ agent-sdk-client/handler.py | 10 +++++++ tests/test_command_whitelist.py | 44 +++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 agent-sdk-client/config.toml create mode 100644 tests/test_command_whitelist.py diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 791b468..32ecb26 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -1,6 +1,44 @@ """Configuration for sdk-client Lambda.""" +import logging import os +import tomllib from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) +DEFAULT_CONFIG_PATH = Path(__file__).with_name("config.toml") + + +def extract_command(text: Optional[str]) -> Optional[str]: + """Extract command (with leading slash) from text, ignoring arguments/bot names.""" + if not text: + return None + + trimmed = text.strip() + if not trimmed.startswith('/'): + return None + + command = trimmed.split()[0] + if '@' in command: + command = command.split('@', 1)[0] + return command or None + + +def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str]: + """Load command whitelist from TOML config file.""" + if not config_path.exists(): + return [] + + try: + with config_path.open('rb') as f: + data = tomllib.load(f) + whitelist = data.get('white_list_commands', {}).get('whitelist', []) + if isinstance(whitelist, list): + return [cmd for cmd in whitelist if isinstance(cmd, str)] + except Exception as exc: # pragma: no cover - defensive logging + logger.warning(f"Failed to load command whitelist: {exc}") + return [] @dataclass @@ -11,13 +49,22 @@ class Config: agent_server_url: str auth_token: str queue_url: str + command_whitelist: list[str] @classmethod - def from_env(cls) -> 'Config': + def from_env(cls, config_path: Optional[Path] = None) -> 'Config': """Load configuration from environment variables.""" return cls( telegram_token=os.getenv('TELEGRAM_BOT_TOKEN', ''), agent_server_url=os.getenv('AGENT_SERVER_URL', ''), auth_token=os.getenv('SDK_CLIENT_AUTH_TOKEN', 'default-token'), queue_url=os.getenv('QUEUE_URL', ''), + command_whitelist=load_command_whitelist(config_path or DEFAULT_CONFIG_PATH), ) + + def is_command_allowed(self, text: Optional[str]) -> bool: + """Check whether text should be forwarded to Agent backend.""" + command = extract_command(text) + if command is None: + return True + return command in self.command_whitelist diff --git a/agent-sdk-client/config.toml b/agent-sdk-client/config.toml new file mode 100644 index 0000000..a01d3f2 --- /dev/null +++ b/agent-sdk-client/config.toml @@ -0,0 +1,6 @@ +[white_list_commands] +# Only commands in this whitelist will be forwarded to the Agent backend +whitelist = [ + "/custom-skill", + "/hello-world", +] diff --git a/agent-sdk-client/consumer.py b/agent-sdk-client/consumer.py index 673bb6c..ee4a0d4 100644 --- a/agent-sdk-client/consumer.py +++ b/agent-sdk-client/consumer.py @@ -55,6 +55,16 @@ async def process_message(message_data: dict) -> None: logger.warning("Received update with no message or edited_message") return + if not config.is_command_allowed(message.text): + logger.info( + "Skipping non-whitelisted command", + extra={ + 'chat_id': message.chat_id, + 'message_id': message.message_id, + }, + ) + return + # Send typing indicator await bot.send_chat_action( chat_id=message.chat_id, diff --git a/agent-sdk-client/handler.py b/agent-sdk-client/handler.py index f681358..4d2639d 100644 --- a/agent-sdk-client/handler.py +++ b/agent-sdk-client/handler.py @@ -147,6 +147,16 @@ def lambda_handler(event: dict, context: Any) -> dict: logger.debug('Ignoring webhook without text message') return {'statusCode': 200} + if not config.is_command_allowed(message.text): + logger.info( + 'Ignoring non-whitelisted command', + extra={ + 'chat_id': message.chat_id, + 'message_id': message.message_id, + }, + ) + return {'statusCode': 200} + # Write to SQS for async processing sqs = _get_sqs_client() message_body = { diff --git a/tests/test_command_whitelist.py b/tests/test_command_whitelist.py new file mode 100644 index 0000000..8e79717 --- /dev/null +++ b/tests/test_command_whitelist.py @@ -0,0 +1,44 @@ +import sys +from pathlib import Path + +import pytest + +CLIENT_DIR = Path(__file__).resolve().parent.parent / "agent-sdk-client" +if str(CLIENT_DIR) not in sys.path: + sys.path.append(str(CLIENT_DIR)) + +from config import Config, load_command_whitelist # noqa: E402 + + +def test_load_command_whitelist(tmp_path): + config_path = tmp_path / "config.toml" + config_path.write_text( + """[white_list_commands] +whitelist = ["/allowed", "/another"] +""" + ) + + assert load_command_whitelist(config_path) == ["/allowed", "/another"] + + +@pytest.mark.parametrize( + "text,expected", + [ + ("hello world", True), + ("/allowed", True), + ("/allowed extra args", True), + ("/allowed@bot", True), + ("/blocked", False), + (" /blocked ", False), + ], +) +def test_is_command_allowed(text, expected): + cfg = Config( + telegram_token="", + agent_server_url="", + auth_token="", + queue_url="", + command_whitelist=["/allowed"], + ) + + assert cfg.is_command_allowed(text) is expected From 06e7f4c8d675be60ce6cc59a8a2845d77f8b56b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:51:32 +0000 Subject: [PATCH 03/11] chore: harden whitelist parsing Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 32ecb26..b503629 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -19,7 +19,11 @@ def extract_command(text: Optional[str]) -> Optional[str]: if not trimmed.startswith('/'): return None - command = trimmed.split()[0] + parts = trimmed.split() + if not parts: + return None + + command = parts[0] if '@' in command: command = command.split('@', 1)[0] return command or None @@ -36,7 +40,7 @@ def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str] whitelist = data.get('white_list_commands', {}).get('whitelist', []) if isinstance(whitelist, list): return [cmd for cmd in whitelist if isinstance(cmd, str)] - except Exception as exc: # pragma: no cover - defensive logging + except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging logger.warning(f"Failed to load command whitelist: {exc}") return [] From d290c05a0a20db7db02e48d2cc93025e95728ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:52:25 +0000 Subject: [PATCH 04/11] chore: improve whitelist load logging Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index b503629..7b42a13 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -41,7 +41,7 @@ def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str] if isinstance(whitelist, list): return [cmd for cmd in whitelist if isinstance(cmd, str)] except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging - logger.warning(f"Failed to load command whitelist: {exc}") + logger.warning("Failed to load command whitelist: %s", exc) return [] From 3bd64bd67380ebe1d79598cf72d763348f60f6c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:53:21 +0000 Subject: [PATCH 05/11] chore: guard empty command extraction Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 7b42a13..dfa05f9 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -26,7 +26,9 @@ def extract_command(text: Optional[str]) -> Optional[str]: command = parts[0] if '@' in command: command = command.split('@', 1)[0] - return command or None + if not command: + return None + return command def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str]: From dff0f7b9bd5fbc1bba881a7d58fcd417c427d119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:54:17 +0000 Subject: [PATCH 06/11] chore: log invalid whitelist entries Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index dfa05f9..12d7431 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -41,7 +41,10 @@ def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str] data = tomllib.load(f) whitelist = data.get('white_list_commands', {}).get('whitelist', []) if isinstance(whitelist, list): - return [cmd for cmd in whitelist if isinstance(cmd, str)] + commands = [cmd for cmd in whitelist if isinstance(cmd, str)] + if len(commands) != len(whitelist): + logger.warning("Ignoring non-string entries in command whitelist") + return commands except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging logger.warning("Failed to load command whitelist: %s", exc) return [] From 6fa3f5fe27a056b7e708455cf7f9f4d693c13af6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:55:37 +0000 Subject: [PATCH 07/11] chore: address review feedback Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 19 +++++++++---------- tests/test_command_whitelist.py | 14 +++++++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 12d7431..5c7ac70 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -19,11 +19,7 @@ def extract_command(text: Optional[str]) -> Optional[str]: if not trimmed.startswith('/'): return None - parts = trimmed.split() - if not parts: - return None - - command = parts[0] + command = trimmed.split()[0] if '@' in command: command = command.split('@', 1)[0] if not command: @@ -40,11 +36,14 @@ def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str] with config_path.open('rb') as f: data = tomllib.load(f) whitelist = data.get('white_list_commands', {}).get('whitelist', []) - if isinstance(whitelist, list): - commands = [cmd for cmd in whitelist if isinstance(cmd, str)] - if len(commands) != len(whitelist): - logger.warning("Ignoring non-string entries in command whitelist") - return commands + if not isinstance(whitelist, list): + logger.warning("Command whitelist is not a list; ignoring configuration") + return [] + + commands = [cmd for cmd in whitelist if isinstance(cmd, str)] + if len(commands) != len(whitelist): + logger.warning("Ignoring non-string entries in command whitelist") + return commands except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging logger.warning("Failed to load command whitelist: %s", exc) return [] diff --git a/tests/test_command_whitelist.py b/tests/test_command_whitelist.py index 8e79717..7d1ef84 100644 --- a/tests/test_command_whitelist.py +++ b/tests/test_command_whitelist.py @@ -1,13 +1,17 @@ -import sys +import importlib.util from pathlib import Path import pytest -CLIENT_DIR = Path(__file__).resolve().parent.parent / "agent-sdk-client" -if str(CLIENT_DIR) not in sys.path: - sys.path.append(str(CLIENT_DIR)) +CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py" -from config import Config, load_command_whitelist # noqa: E402 +spec = importlib.util.spec_from_file_location("client_config", CLIENT_CONFIG_PATH) +config_module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(config_module) + +Config = config_module.Config +load_command_whitelist = config_module.load_command_whitelist def test_load_command_whitelist(tmp_path): From 1edb21847a88e238d6d2334332e1fd7fb52f0a5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:57:14 +0000 Subject: [PATCH 08/11] chore: refine command extraction and tests Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 3 ++- tests/test_command_whitelist.py | 15 ++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 5c7ac70..28df429 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -22,7 +22,8 @@ def extract_command(text: Optional[str]) -> Optional[str]: command = trimmed.split()[0] if '@' in command: command = command.split('@', 1)[0] - if not command: + command = command.strip() + if not command or command == '/': return None return command diff --git a/tests/test_command_whitelist.py b/tests/test_command_whitelist.py index 7d1ef84..5045ba1 100644 --- a/tests/test_command_whitelist.py +++ b/tests/test_command_whitelist.py @@ -1,17 +1,12 @@ -import importlib.util +import runpy from pathlib import Path import pytest CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py" - -spec = importlib.util.spec_from_file_location("client_config", CLIENT_CONFIG_PATH) -config_module = importlib.util.module_from_spec(spec) -assert spec and spec.loader -spec.loader.exec_module(config_module) - -Config = config_module.Config -load_command_whitelist = config_module.load_command_whitelist +config_module = runpy.run_path(CLIENT_CONFIG_PATH) +Config = config_module["Config"] +load_command_whitelist = config_module["load_command_whitelist"] def test_load_command_whitelist(tmp_path): @@ -32,6 +27,8 @@ def test_load_command_whitelist(tmp_path): ("/allowed", True), ("/allowed extra args", True), ("/allowed@bot", True), + ("/@bot", True), + ("/", True), ("/blocked", False), (" /blocked ", False), ], From 5b17e43ccdf3e91aaf3bed10572b627247ada841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:11:08 +0000 Subject: [PATCH 09/11] feat: respond to blocked commands locally Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/consumer.py | 9 ++++++++ agent-sdk-client/handler.py | 43 ++++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/agent-sdk-client/consumer.py b/agent-sdk-client/consumer.py index ee4a0d4..36b5d77 100644 --- a/agent-sdk-client/consumer.py +++ b/agent-sdk-client/consumer.py @@ -63,6 +63,15 @@ async def process_message(message_data: dict) -> None: 'message_id': message.message_id, }, ) + try: + await bot.send_message( + chat_id=message.chat_id, + text="Unsupported command.", + message_thread_id=message.message_thread_id, + reply_to_message_id=message.message_id, + ) + except Exception: + logger.warning("Failed to send local command response", exc_info=True) return # Send typing indicator diff --git a/agent-sdk-client/handler.py b/agent-sdk-client/handler.py index 4d2639d..e7f820d 100644 --- a/agent-sdk-client/handler.py +++ b/agent-sdk-client/handler.py @@ -116,10 +116,42 @@ def _send_to_sqs_safe(sqs, queue_url: str, message_body: dict) -> bool: f"Unexpected error sending to SQS: {e}", extra={'exception_type': type(e).__name__}, ) - _send_metric('SQSError.Unexpected') + _send_metric('SQSError.Unexpected') return False +def _handle_local_command(bot: Bot, message, config: Config) -> bool: + """Handle non-whitelisted commands locally to give user feedback.""" + if config.is_command_allowed(message.text): + return False + + allowed = config.command_whitelist + if allowed: + allowed_list = "\n".join(allowed) + text = f"Unsupported command. Allowed commands:\n{allowed_list}" + else: + text = "Unsupported command." + + try: + bot.send_message( + chat_id=message.chat_id, + text=text, + message_thread_id=message.message_thread_id, + reply_to_message_id=message.message_id, + ) + except Exception: + logger.warning("Failed to send local command response", exc_info=True) + + logger.info( + 'Handled non-whitelisted command locally', + extra={ + 'chat_id': message.chat_id, + 'message_id': message.message_id, + }, + ) + return True + + def lambda_handler(event: dict, context: Any) -> dict: """Lambda entry point - Producer. @@ -147,14 +179,7 @@ def lambda_handler(event: dict, context: Any) -> dict: logger.debug('Ignoring webhook without text message') return {'statusCode': 200} - if not config.is_command_allowed(message.text): - logger.info( - 'Ignoring non-whitelisted command', - extra={ - 'chat_id': message.chat_id, - 'message_id': message.message_id, - }, - ) + if _handle_local_command(bot, message, config): return {'statusCode': 200} # Write to SQS for async processing From 85f409fde9282a3949ebce6cee4e32ab74573bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 08:57:33 +0000 Subject: [PATCH 10/11] chore: address review feedback round 2 Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/consumer.py | 11 +++++- agent-sdk-client/handler.py | 2 +- tests/test_command_whitelist.py | 70 +++++++++++++++++++++++++++++++-- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/agent-sdk-client/consumer.py b/agent-sdk-client/consumer.py index 36b5d77..c31f66d 100644 --- a/agent-sdk-client/consumer.py +++ b/agent-sdk-client/consumer.py @@ -56,17 +56,24 @@ async def process_message(message_data: dict) -> None: return if not config.is_command_allowed(message.text): + # Defensive guard: producer should already block non-whitelisted commands. logger.info( - "Skipping non-whitelisted command", + "Skipping non-whitelisted command (consumer fallback)", extra={ 'chat_id': message.chat_id, 'message_id': message.message_id, }, ) + allowed = config.command_whitelist + if allowed: + allowed_list = "\n".join(allowed) + text = f"Unsupported command. Allowed commands:\n{allowed_list}" + else: + text = "Unsupported command." try: await bot.send_message( chat_id=message.chat_id, - text="Unsupported command.", + text=text, message_thread_id=message.message_thread_id, reply_to_message_id=message.message_id, ) diff --git a/agent-sdk-client/handler.py b/agent-sdk-client/handler.py index e7f820d..2641626 100644 --- a/agent-sdk-client/handler.py +++ b/agent-sdk-client/handler.py @@ -116,7 +116,7 @@ def _send_to_sqs_safe(sqs, queue_url: str, message_body: dict) -> bool: f"Unexpected error sending to SQS: {e}", extra={'exception_type': type(e).__name__}, ) - _send_metric('SQSError.Unexpected') + _send_metric('SQSError.Unexpected') return False diff --git a/tests/test_command_whitelist.py b/tests/test_command_whitelist.py index 5045ba1..eb2f00d 100644 --- a/tests/test_command_whitelist.py +++ b/tests/test_command_whitelist.py @@ -1,12 +1,15 @@ -import runpy +import importlib.util from pathlib import Path import pytest CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py" -config_module = runpy.run_path(CLIENT_CONFIG_PATH) -Config = config_module["Config"] -load_command_whitelist = config_module["load_command_whitelist"] +spec = importlib.util.spec_from_file_location("agent_sdk_client_config", CLIENT_CONFIG_PATH) +config_module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(config_module) +Config = config_module.Config +load_command_whitelist = config_module.load_command_whitelist def test_load_command_whitelist(tmp_path): @@ -43,3 +46,62 @@ def test_is_command_allowed(text, expected): ) assert cfg.is_command_allowed(text) is expected + + +def test_empty_whitelist_allows_commands(): + cfg = Config( + telegram_token="", + agent_server_url="", + auth_token="", + queue_url="", + command_whitelist=[], + ) + assert cfg.is_command_allowed("/anything") is False + + +def test_none_text_treated_as_allowed(): + cfg = Config( + telegram_token="", + agent_server_url="", + auth_token="", + queue_url="", + command_whitelist=["/allowed"], + ) + assert cfg.is_command_allowed(None) + + +def test_load_whitelist_non_string_entries(tmp_path): + config_path = tmp_path / "config.toml" + config_path.write_text( + """[white_list_commands] +whitelist = ["/ok", 123, { a = 1 }] +""" + ) + assert load_command_whitelist(config_path) == ["/ok"] + + +def test_load_whitelist_invalid_type_logs_and_returns_empty(tmp_path, caplog): + config_path = tmp_path / "config.toml" + config_path.write_text( + """[white_list_commands] +whitelist = "not-a-list" +""" + ) + with caplog.at_level("WARNING"): + result = load_command_whitelist(config_path) + assert result == [] + assert any("Command whitelist is not a list" in rec.message for rec in caplog.records) + + +def test_load_whitelist_missing_file_returns_empty(tmp_path): + missing = tmp_path / "missing.toml" + assert load_command_whitelist(missing) == [] + + +def test_load_whitelist_malformed_toml_returns_empty(tmp_path, caplog): + config_path = tmp_path / "config.toml" + config_path.write_text("not = [ [") + with caplog.at_level("WARNING"): + result = load_command_whitelist(config_path) + assert result == [] + assert any("Failed to load command whitelist" in rec.message for rec in caplog.records) From 217506283b0e030f80f9c5d8e2be0bfc0c35c896 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:15:08 +0000 Subject: [PATCH 11/11] feat: separate agent vs local commands Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com> --- agent-sdk-client/config.py | 70 +++++++++++++------ agent-sdk-client/config.toml | 10 ++- agent-sdk-client/consumer.py | 60 +++++++++------- agent-sdk-client/handler.py | 23 ++++--- tests/test_command_config.py | 117 ++++++++++++++++++++++++++++++++ tests/test_command_whitelist.py | 107 ----------------------------- 6 files changed, 220 insertions(+), 167 deletions(-) create mode 100644 tests/test_command_config.py delete mode 100644 tests/test_command_whitelist.py diff --git a/agent-sdk-client/config.py b/agent-sdk-client/config.py index 28df429..8dfdca7 100644 --- a/agent-sdk-client/config.py +++ b/agent-sdk-client/config.py @@ -28,26 +28,34 @@ def extract_command(text: Optional[str]) -> Optional[str]: return command -def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str]: - """Load command whitelist from TOML config file.""" +def _load_config(config_path: Path = DEFAULT_CONFIG_PATH) -> tuple[list[str], dict[str, str]]: + """Load agent/local commands from TOML config file.""" if not config_path.exists(): - return [] + return [], {} try: with config_path.open('rb') as f: data = tomllib.load(f) - whitelist = data.get('white_list_commands', {}).get('whitelist', []) - if not isinstance(whitelist, list): - logger.warning("Command whitelist is not a list; ignoring configuration") - return [] - - commands = [cmd for cmd in whitelist if isinstance(cmd, str)] - if len(commands) != len(whitelist): - logger.warning("Ignoring non-string entries in command whitelist") - return commands + agent_commands = data.get('agent_commands', {}).get('commands', []) + if not isinstance(agent_commands, list): + logger.warning("Agent commands config is not a list; ignoring configuration") + agent_commands = [] + agent_commands = [cmd for cmd in agent_commands if isinstance(cmd, str)] + + local_commands_raw = data.get('local_commands', {}) + if not isinstance(local_commands_raw, dict): + logger.warning("Local commands config is not a table; ignoring configuration") + local_commands_raw = {} + local_commands = { + f"/{name.lstrip('/')}" if not name.startswith('/') else name: str(value) + for name, value in local_commands_raw.items() + if isinstance(name, str) and isinstance(value, str) + } + + return agent_commands, local_commands except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging - logger.warning("Failed to load command whitelist: %s", exc) - return [] + logger.warning("Failed to load command configuration: %s", exc) + return [], {} @dataclass @@ -58,22 +66,40 @@ class Config: agent_server_url: str auth_token: str queue_url: str - command_whitelist: list[str] + agent_commands: list[str] + local_commands: dict[str, str] @classmethod def from_env(cls, config_path: Optional[Path] = None) -> 'Config': """Load configuration from environment variables.""" + agent_cmds, local_cmds = _load_config(config_path or DEFAULT_CONFIG_PATH) return cls( telegram_token=os.getenv('TELEGRAM_BOT_TOKEN', ''), agent_server_url=os.getenv('AGENT_SERVER_URL', ''), auth_token=os.getenv('SDK_CLIENT_AUTH_TOKEN', 'default-token'), queue_url=os.getenv('QUEUE_URL', ''), - command_whitelist=load_command_whitelist(config_path or DEFAULT_CONFIG_PATH), + agent_commands=agent_cmds, + local_commands=local_cmds, ) - def is_command_allowed(self, text: Optional[str]) -> bool: - """Check whether text should be forwarded to Agent backend.""" - command = extract_command(text) - if command is None: - return True - return command in self.command_whitelist + def get_command(self, text: Optional[str]) -> Optional[str]: + return extract_command(text) + + def is_agent_command(self, cmd: Optional[str]) -> bool: + return bool(cmd) and cmd in self.agent_commands + + def is_local_command(self, cmd: Optional[str]) -> bool: + return bool(cmd) and cmd in self.local_commands + + def local_response(self, cmd: str) -> str: + return self.local_commands.get(cmd, "Unsupported command.") + + def unknown_command_message(self) -> str: + parts = [] + if self.agent_commands: + parts.append("Agent commands:\n" + "\n".join(self.agent_commands)) + if self.local_commands: + parts.append("Local commands:\n" + "\n".join(self.local_commands.keys())) + if not parts: + return "Unsupported command." + return "Unsupported command.\n\n" + "\n\n".join(parts) diff --git a/agent-sdk-client/config.toml b/agent-sdk-client/config.toml index a01d3f2..4186b4e 100644 --- a/agent-sdk-client/config.toml +++ b/agent-sdk-client/config.toml @@ -1,6 +1,10 @@ -[white_list_commands] -# Only commands in this whitelist will be forwarded to the Agent backend -whitelist = [ +[agent_commands] +# Commands forwarded to the Agent backend +commands = [ "/custom-skill", "/hello-world", ] + +[local_commands] +# Local-only commands handled by the client +help = "Hello World" diff --git a/agent-sdk-client/consumer.py b/agent-sdk-client/consumer.py index c31f66d..52faf6a 100644 --- a/agent-sdk-client/consumer.py +++ b/agent-sdk-client/consumer.py @@ -55,31 +55,43 @@ async def process_message(message_data: dict) -> None: logger.warning("Received update with no message or edited_message") return - if not config.is_command_allowed(message.text): - # Defensive guard: producer should already block non-whitelisted commands. - logger.info( - "Skipping non-whitelisted command (consumer fallback)", - extra={ - 'chat_id': message.chat_id, - 'message_id': message.message_id, - }, - ) - allowed = config.command_whitelist - if allowed: - allowed_list = "\n".join(allowed) - text = f"Unsupported command. Allowed commands:\n{allowed_list}" - else: - text = "Unsupported command." - try: - await bot.send_message( - chat_id=message.chat_id, - text=text, - message_thread_id=message.message_thread_id, - reply_to_message_id=message.message_id, + cmd = config.get_command(message.text) + if cmd: + if config.is_local_command(cmd): + logger.info( + "Handling local command in consumer (fallback path)", + extra={'chat_id': message.chat_id, 'message_id': message.message_id}, ) - except Exception: - logger.warning("Failed to send local command response", exc_info=True) - return + try: + await bot.send_message( + chat_id=message.chat_id, + text=config.local_response(cmd), + message_thread_id=message.message_thread_id, + reply_to_message_id=message.message_id, + ) + except Exception: + logger.warning("Failed to send local command response", exc_info=True) + return + + if not config.is_agent_command(cmd): + # Defensive guard: producer should already block non-agent commands. + logger.info( + "Skipping non-agent command (consumer fallback)", + extra={ + 'chat_id': message.chat_id, + 'message_id': message.message_id, + }, + ) + try: + await bot.send_message( + chat_id=message.chat_id, + text=config.unknown_command_message(), + message_thread_id=message.message_thread_id, + reply_to_message_id=message.message_id, + ) + except Exception: + logger.warning("Failed to send local command response", exc_info=True) + return # Send typing indicator await bot.send_chat_action( diff --git a/agent-sdk-client/handler.py b/agent-sdk-client/handler.py index 2641626..ae3cd9f 100644 --- a/agent-sdk-client/handler.py +++ b/agent-sdk-client/handler.py @@ -120,17 +120,12 @@ def _send_to_sqs_safe(sqs, queue_url: str, message_body: dict) -> bool: return False -def _handle_local_command(bot: Bot, message, config: Config) -> bool: - """Handle non-whitelisted commands locally to give user feedback.""" - if config.is_command_allowed(message.text): - return False - - allowed = config.command_whitelist - if allowed: - allowed_list = "\n".join(allowed) - text = f"Unsupported command. Allowed commands:\n{allowed_list}" +def _handle_local_command(bot: Bot, message, config: Config, cmd: str) -> bool: + """Handle local commands or unknown commands.""" + if config.is_local_command(cmd): + text = config.local_response(cmd) else: - text = "Unsupported command." + text = config.unknown_command_message() try: bot.send_message( @@ -179,7 +174,13 @@ def lambda_handler(event: dict, context: Any) -> dict: logger.debug('Ignoring webhook without text message') return {'statusCode': 200} - if _handle_local_command(bot, message, config): + cmd = config.get_command(message.text) + if cmd and config.is_local_command(cmd): + _handle_local_command(bot, message, config, cmd) + return {'statusCode': 200} + + if cmd and not config.is_agent_command(cmd): + _handle_local_command(bot, message, config, cmd) return {'statusCode': 200} # Write to SQS for async processing diff --git a/tests/test_command_config.py b/tests/test_command_config.py new file mode 100644 index 0000000..e268340 --- /dev/null +++ b/tests/test_command_config.py @@ -0,0 +1,117 @@ +import importlib.util +from pathlib import Path + +import pytest + +CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py" +spec = importlib.util.spec_from_file_location("agent_sdk_client_config", CLIENT_CONFIG_PATH) +config_module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(config_module) +Config = config_module.Config +extract_command = config_module.extract_command + + +def load_config_from_text(text: str, tmp_path: Path) -> Config: + config_path = tmp_path / "config.toml" + config_path.write_text(text) + return Config.from_env(config_path=config_path) + + +def test_load_agent_and_local_commands(tmp_path): + cfg = load_config_from_text( + """[agent_commands] +commands = ["/a", "/b"] + +[local_commands] +help = "Hello" +""", + tmp_path, + ) + assert cfg.agent_commands == ["/a", "/b"] + assert cfg.local_commands == {"/help": "Hello"} + + +@pytest.mark.parametrize( + "text,cmd", + [ + ("hello world", None), + ("/allowed", "/allowed"), + ("/allowed extra args", "/allowed"), + ("/allowed@bot", "/allowed"), + ("/@bot", None), + ("/", None), + (None, None), + ], +) +def test_extract_command(text, cmd): + assert extract_command(text) == cmd + + +def test_command_classification(tmp_path): + cfg = load_config_from_text( + """[agent_commands] +commands = ["/agent"] + +[local_commands] +help = "Hello World" +""", + tmp_path, + ) + assert cfg.is_agent_command("/agent") + assert not cfg.is_agent_command("/other") + assert cfg.is_local_command("/help") + assert not cfg.is_local_command("/agent") + + +def test_unknown_command_message_lists_known(): + cfg = Config( + telegram_token="", + agent_server_url="", + auth_token="", + queue_url="", + agent_commands=["/agent1"], + local_commands={"/help": "hi"}, + ) + msg = cfg.unknown_command_message() + assert "Agent commands" in msg and "/agent1" in msg + assert "Local commands" in msg and "/help" in msg + + +def test_invalid_agent_commands_type(tmp_path, caplog): + with caplog.at_level("WARNING"): + cfg = load_config_from_text( + """[agent_commands] +commands = "not-a-list" +""", + tmp_path, + ) + assert cfg.agent_commands == [] + assert any("Agent commands config is not a list" in rec.message for rec in caplog.records) + + +def test_invalid_local_commands_type(tmp_path, caplog): + cfg = load_config_from_text( + """[local_commands] +value = 1 +""", + tmp_path, + ) + assert cfg.local_commands == {} + + +def test_missing_config_file(tmp_path): + missing = tmp_path / "missing.toml" + cfg = Config.from_env(config_path=missing) + assert cfg.agent_commands == [] + assert cfg.local_commands == {} + + +def test_malformed_toml_returns_empty(tmp_path, caplog): + path = tmp_path / "bad.toml" + path.write_text("not = [ [") + with caplog.at_level("WARNING"): + cfg = Config.from_env(config_path=path) + assert cfg.agent_commands == [] + assert cfg.local_commands == {} + assert any("Failed to load command configuration" in rec.message for rec in caplog.records) diff --git a/tests/test_command_whitelist.py b/tests/test_command_whitelist.py deleted file mode 100644 index eb2f00d..0000000 --- a/tests/test_command_whitelist.py +++ /dev/null @@ -1,107 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py" -spec = importlib.util.spec_from_file_location("agent_sdk_client_config", CLIENT_CONFIG_PATH) -config_module = importlib.util.module_from_spec(spec) -assert spec.loader is not None -spec.loader.exec_module(config_module) -Config = config_module.Config -load_command_whitelist = config_module.load_command_whitelist - - -def test_load_command_whitelist(tmp_path): - config_path = tmp_path / "config.toml" - config_path.write_text( - """[white_list_commands] -whitelist = ["/allowed", "/another"] -""" - ) - - assert load_command_whitelist(config_path) == ["/allowed", "/another"] - - -@pytest.mark.parametrize( - "text,expected", - [ - ("hello world", True), - ("/allowed", True), - ("/allowed extra args", True), - ("/allowed@bot", True), - ("/@bot", True), - ("/", True), - ("/blocked", False), - (" /blocked ", False), - ], -) -def test_is_command_allowed(text, expected): - cfg = Config( - telegram_token="", - agent_server_url="", - auth_token="", - queue_url="", - command_whitelist=["/allowed"], - ) - - assert cfg.is_command_allowed(text) is expected - - -def test_empty_whitelist_allows_commands(): - cfg = Config( - telegram_token="", - agent_server_url="", - auth_token="", - queue_url="", - command_whitelist=[], - ) - assert cfg.is_command_allowed("/anything") is False - - -def test_none_text_treated_as_allowed(): - cfg = Config( - telegram_token="", - agent_server_url="", - auth_token="", - queue_url="", - command_whitelist=["/allowed"], - ) - assert cfg.is_command_allowed(None) - - -def test_load_whitelist_non_string_entries(tmp_path): - config_path = tmp_path / "config.toml" - config_path.write_text( - """[white_list_commands] -whitelist = ["/ok", 123, { a = 1 }] -""" - ) - assert load_command_whitelist(config_path) == ["/ok"] - - -def test_load_whitelist_invalid_type_logs_and_returns_empty(tmp_path, caplog): - config_path = tmp_path / "config.toml" - config_path.write_text( - """[white_list_commands] -whitelist = "not-a-list" -""" - ) - with caplog.at_level("WARNING"): - result = load_command_whitelist(config_path) - assert result == [] - assert any("Command whitelist is not a list" in rec.message for rec in caplog.records) - - -def test_load_whitelist_missing_file_returns_empty(tmp_path): - missing = tmp_path / "missing.toml" - assert load_command_whitelist(missing) == [] - - -def test_load_whitelist_malformed_toml_returns_empty(tmp_path, caplog): - config_path = tmp_path / "config.toml" - config_path.write_text("not = [ [") - with caplog.at_level("WARNING"): - result = load_command_whitelist(config_path) - assert result == [] - assert any("Failed to load command whitelist" in rec.message for rec in caplog.records)