Legio v3 — Fix Everything, Improve Everything
Context
Comprehensive audit against dev-memo/plan.md revealed 3 high-severity issues (path traversal, dead token tracking, imprecise SQL), 4 medium issues, and numerous refactoring/improvement opportunities. All 230 tests pass at 100% coverage. This plan fixes all gaps, bugs, and security holes while maintaining that 100% green bar.
Work Items (ordered by priority)
1. Security: Path traversal protection in MemoriaStore + Legatus
Files: legio/memoria/store.py, legio/legatus.py, legio/errors.py, tests/test_memoria_store.py, tests/test_legatus.py
- Add
_validate_name(name: str) -> Nonehelper instore.pythat raisesMemoriaErrorif name doesn't match^[a-z0-9][a-z0-9_-]*$or contains path separators. - Call it at the top of every public method that takes
nameorcenturio_name:read_edictum,publish_edictum,revoke_edictum,read_actum,publish_actum,read_commentarium,write_commentarium,list_commentarii. - Add same validation in
legatus._create_centurio()for centurio name. - Add
_RESERVED_NAMES = frozenset({"caesar", "legatus", "all", "praetorium"})— block these as centurio names. - Add
# SECURITY: prevent path traversal via crafted namecomments. - Add
@pytest.mark.securitytests: null bytes,../, absolute paths, reserved names.
2. Bug fix: Audience LIKE query false positives in Praetorium
Files: legio/praetorium.py, tests/test_praetorium.py
- Replace the
LIKE '%"viewer"%'query with application-level filtering. Query all nuntii ordered by timestamp DESC with a larger over-fetch limit, then filter in Python usingNuntius.is_visible_to(), and return only the firstlimitmatches. - Alternative considered:
json_each()— but aiosqlite's SQLite version may not support it reliably on all platforms. Application-level is safer and uses the existingis_visible_to()method. - Add
@pytest.mark.securitytest: centurio "a" must NOT see messages for "alpha".
3. Bug fix: Token tracker never updated (D19 dead code)
Files: legio/legatus.py, tests/test_legatus.py
- Modify
_collect_response()to accept an optionalSessionTokenTrackerand calltracker.update(msg)on eachResultMessage. - In
dispatch_to_centurio(), passsession.trackerto_collect_response(). - Add test verifying that after dispatch, session tracker has non-zero cumulative_input.
4. Feature: Start idle session reaper task (D29)
Files: legio/legatus.py, legio/__main__.py, tests/test_legatus.py
- Add
start_idle_reaper()method toLegatusthat creates the periodicasyncio.Taskstored inself._idle_task. Loop:await asyncio.sleep(60), thenawait self.cleanup_idle_sessions(). - Call
legatus.start_idle_reaper()in__main__.pyafter legatus creation, before bot start. shutdown()already cancels_idle_task— no change needed.- Add test that reaper task runs and cleans up.
5. Security: XML escaping in format_history_xml and memoria XML wrappers
Files: legio/legatus.py, legio/memoria/store.py, tests/test_legatus.py, tests/test_memoria_store.py
- Use
xml.sax.saxutils.escape()for text content andxml.sax.saxutils.quoteattr()for attribute values. - In
format_history_xml(): escapen.text,n.sender,n.id. - In
_wrap_xml()and_wrap_commentarium_xml(): escapename,author,content. - Add
# SECURITY: prevent XML injection in context blockscomments. - Add
@pytest.mark.securitytests with payloads like</nuntius><injected>.
6. Refactor: Deduplicate MCP tool builders in memoria/tools.py
Files: legio/memoria/tools.py, tests/test_memoria_tools.py
- Extract shared read-only tools (list_edicta, read_edictum, list_acta, read_actum) into a
_build_shared_readonly_tools(store)helper function. build_memoria_full_server= shared + write edicta + revoke + write acta + full commentarii.build_memoria_centurio_server= shared + scoped write acta + scoped commentarii.- ~40 lines eliminated. Tests unchanged (same public API).
7. Improvement: Add centurio name validation on Centurio dataclass
Files: legio/centurio.py, tests/test_centurio.py
- Extend
__post_init__to reject names matching reserved names and names with invalid characters (path separators, dots, etc). Pattern:^[a-z][a-z0-9_-]*$. - This provides defense-in-depth alongside MemoriaStore validation.
8. Improvement: Add # SECURITY: comment on Telegram error boundary
Files: legio/telegram/bot.py
- Add
# SECURITY: generic error prevents leaking internal details to chatatbot.py:165.
9. Fix: .claude/rules/ path scoping
Files: All .claude/rules/**/*.md files
- Change
paths: ["src/**/*.py"]topaths: ["legio/**/*.py"]andpaths: ["legio/**/*.py", "tests/**/*.py"]as appropriate. The project has nosrc/directory.
10. Improvement: Missing templates/centurio/ directory (D20)
Files: templates/centurio/prompt.md.template, templates/centurio/tools.json.template
- Create both template files on disk matching plan spec.
- The
_render_centurio_prompt()inline template stays (it's the runtime implementation) but now the templates serve as the canonical editable scaffolds per "prompt over code."
11. Improvement: Startup warning for unconfigured telegram_id
Files: legio/__main__.py
- Add
if config.caesar.telegram_id == 0: logger.warning("telegram_id is 0 — bot will reject all users")after config load.
12. Compact: Remove unused MemoriaServer.config field
Files: legio/memoria/tools.py, tests/test_memoria_tools.py
- Remove the
configfield fromMemoriaServerdataclass. Removecreate_sdk_mcp_server()calls inside both builder functions — they're only used to populate this field. The actual MCP server config is built by the Legatus when it composes tools. - Update the test that checks
server.config.
13. Improvement: WAL mode for SQLite
Files: legio/praetorium.py
- Add
await self._db.execute("PRAGMA journal_mode=WAL")after connection inopen().
14. Minor: /create should show working indicator (D21 consistency)
Files: legio/telegram/bot.py
- Add ⏳ + typing indicator pattern to
_handle_create, matching_handle_message.
Verification
After all changes:
# Must all pass
cd /Users/joker/github/xiaolai/myprojects/legio
.venv/bin/pytest # 100% coverage, all green
.venv/bin/ruff check legio/ tests/ # zero violations
.venv/bin/ruff format --check legio/ tests/ # formatting cleanSecurity tests specifically:
.venv/bin/pytest -m security -v # all @pytest.mark.security pass