diff --git a/cli.py b/cli.py index 7236d98..c5401b5 100644 --- a/cli.py +++ b/cli.py @@ -156,7 +156,11 @@ def main(): elif args.cmd == "wizard": _run_wizard(args) elif args.cmd == "task": - tid, size = commands.submit_task(args.title, args.details, args.minutes) + try: + tid, size = commands.submit_task(args.title, args.details, args.minutes) + except ValueError as e: + print(f"ERROR: {e}") + sys.exit(1) if args.await_delivery: commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой") print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)") diff --git a/harness/commands.py b/harness/commands.py index a5ae5d1..57e5aee 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -4,6 +4,7 @@ валидируется и только потом пишет событие. """ import os +import re import secrets from datetime import datetime, timezone @@ -67,8 +68,29 @@ def list_escalations(only_open: bool = False): return out +# мусорные заголовки: LLM-тик нижнего уровня звал `cli.py task list` / `cli.py task `, +# а argparse принял субкомандо-подобное слово / id как title → мусорные задачи 'list' +# (e-261001082241, e-261003094628). Ловим на входе command side. +_SUSPECT_TITLES = {"list", "tasks", "status", "events", "help", "complete", "promote", "requeue"} +_TASK_ID_RE = re.compile(r"^t-\d{12}-[0-9a-f]{6}$") + + +def _validate_task_title(title: str, details: str): + """Guard: заголовок-субкоманда CLI или чужой id задачи без деталей — это просмотр, не подача.""" + if not title or not title.strip(): + raise ValueError("пустой заголовок задачи") + t = title.strip() + if not details.strip() and (t.lower() in _SUSPECT_TITLES or _TASK_ID_RE.match(t)): + raise ValueError( + f"подозрительный заголовок {t!r} без деталей: похоже на субкоманду CLI или id задачи " + f"(cli.py task list / cli.py task — это просмотр, не создание). " + "Подача: cli.py task \"заголовок\" -d \"детали\"; доска: cli.py tasks" + ) + + def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "cli"): """SubmitTask: класс по оценке длительности. Идемпотентно по id.""" + _validate_task_title(title, details) task_id = f"t-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{secrets.token_hex(3)}" size = size_class(minutes) es.append_event("task", task_id, "TaskSubmitted", { diff --git a/tests/test_submit_title_guard.py b/tests/test_submit_title_guard.py new file mode 100644 index 0000000..5be9b8b --- /dev/null +++ b/tests/test_submit_title_guard.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""e-261003094628 (повтор e-261001082241): guard мусорных заголовков задач (hermetic). + +Запуск: ./venv/bin/python tests/test_submit_title_guard.py + +Первопричина серии 'list': LLM-тик нижнего уровня звал +`cli.py task list` / `cli.py task ` (хотел доску/детали задачи), а +`task` принимает позиционный title → argparse молча подсунул слово +'list' / чужой id как заголовок и подал мусорную задачу. Min-уровень +закрыл их как no-op и эскалировал — дважды. + +Проверяет: submit_task режет субкомандо-подобные заголовки и чужие id +задач БЕЗ деталей (ValueError), пропускает нормальные подачи, одно слово +с деталями, обычные слова не из стоп-листа; CLI `task list` → exit 1 с +ERROR, а не тихая подача. +""" +import os +import subprocess +import sys + +# ── изоляция: своя БД в /tmp (prod не трогаем) ── +_DST = "/tmp/simbiot-test-title-guard.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, "/root/simbiot-harness") + +from harness import commands + +commands.es.init_db() + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "") + if not cond: + fails.append(name) + + +# ── 1. Субкомандо-подобные заголовки без деталей — режутся ── +for bad in ("list", "tasks", "status", "events", "help", "LIST", " Complete "): + try: + commands.submit_task(bad, "", minutes=5) + check(f"мусорный заголовок {bad!r} отвергнут", False, "подача прошла") + except ValueError: + check(f"мусорный заголовок {bad!r} отвергнут", True) + +# ── 2. Чужой id задачи как заголовок без деталей — режется ── +try: + commands.submit_task("t-261003094008-ed226f", "", minutes=5) + check("id задачи как заголовок отвергнут", False, "подача прошла") +except ValueError: + check("id задачи как заголовок отвергнут", True) + +# ── 3. Нормальные подачи — проходят ── +for title, details in ( + ("Разбор голосового m-973940607 + HTML-страница", "транскрипт в docs/"), + ("list", "собрать список всех ключей в реестре"), # слово из стоп-листа, но с деталями + ("фикс деплоя", ""), +): + try: + tid, size = commands.submit_task(title, details, minutes=5) + ok = bool(tid) and size == "S" + except ValueError as e: + ok, tid = False, str(e) + check(f"нормальная подача {title[:30]!r} прошла", ok, tid) + +# ── 4. CLI: `cli.py task list` — ошибка, а не тихая мусорная задача ── +env = dict(os.environ, SIMBIOT_DB=_DST) +r = subprocess.run( + ["./venv/bin/python", "cli.py", "task", "list"], + capture_output=True, text=True, cwd="/root/simbiot-harness", env=env, +) +out = (r.stdout + r.stderr).strip() +check("CLI task list → exit 1", r.returncode == 1, f"rc={r.returncode} out={out!r}") +check("CLI task list → ERROR с подсказкой", "ERROR" in out and "tasks" in out, out) +model = commands.es.run_projections() +check("мусорной задачи 'list' в сторе нет", + not any(t.get("title", "").strip().lower() == "list" and not t.get("details") + for t in model["tasks"].values())) + +print() +if fails: + print(f"FAILED: {len(fails)}") + sys.exit(1) +print("OK: все проверки прошли")