diff --git a/docs/spec/X9-mcp-tool-contract.md b/docs/spec/X9-mcp-tool-contract.md index ef799ae..793e7a1 100644 --- a/docs/spec/X9-mcp-tool-contract.md +++ b/docs/spec/X9-mcp-tool-contract.md @@ -63,15 +63,15 @@ Kleppmann *DDIA* (idempotence) · IETF — *Idempotency-Key header* draft (https **כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד). מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת נגיש). **פרויקטלי-תפעולי.** **מקור-סמכות:** דפוס `extract_claims`↔`get_claims`, `aggregate`↔`get_legal_arguments` ב-[server.py](../../mcp-server/src/legal_mcp/server.py). -**אכיפה:** לכל extract — get מקביל. **כיום מופר:** `extract_appraiser_facts` כותב, **אין `get_appraiser_facts`** → חילוץ-חוזר יקר ולא-דטרמיניסטי. -**הפרה ידועה:** [gap-audit GAP-44](gap-audit.md); תור-חילוץ סמוי ([gap-audit GAP-45](gap-audit.md)). +**אכיפה:** לכל extract — get מקביל. **GAP-44 ✅ נסגר (2026-06-06):** נוסף `get_appraiser_facts` (קורא `list_appraiser_facts`+`detect_appraiser_conflicts`, ללא חילוץ-מחדש). נותר: תור-חילוץ סמוי (GAP-45). +**הפרה ידועה:** תור-חילוץ סמוי ([gap-audit GAP-45](gap-audit.md)). ### INV-TOOL5: limit-caps על כל כלי-רשימה/חיפוש **כלל:** לכל כלי שמחזיר רשימה יש **תקרת-limit נאכפת** (הגנה מפני עומס/DoS); pagination היכן שרלוונטי. **הנדסי.** **מקורות:** OWASP API Security Top 10 — *API4:2023 Unrestricted Resource Consumption* (https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) · Microsoft *REST API Guidelines* (pagination) · Stripe API (limit caps) | סטטוס: verified -**אכיפה:** clamp ל-max בכל כלי-רשימה. **כיום אין** — `precedent_library_list`/`search_*`/`missing_precedent_list` ללא תקרה; `list_chair_feedback` ללא limit כלל ([gap-audit GAP-53](gap-audit.md)). -**הפרה ידועה:** [gap-audit GAP-53](gap-audit.md). +**אכיפה:** clamp ל-max בכל כלי-רשימה. **GAP-53 ✅ נסגר (2026-06-06):** `_clamp_limit` (תקרה 200) על ~13 כלי list/search ב-[server.py](../../mcp-server/src/legal_mcp/server.py); `list_chair_feedback` קיבל param `limit` (server→workflow→db עם `LIMIT`). +**הפרה ידועה:** — ### INV-TOOL6: שלמות-הרשאות — כל כלי שהוראות-הסוכן דורשות מוענק **כלל:** מפת-ההרשאות (אילו כלים מוענקים לכל סוכן) **תואמת** את מה שהוראות-הסוכן מצריכות — לא חסר ולא עודף. diff --git a/docs/spec/gap-audit.md b/docs/spec/gap-audit.md index 2d2af89..47132ef 100644 --- a/docs/spec/gap-audit.md +++ b/docs/spec/gap-audit.md @@ -198,6 +198,7 @@ ### FU-14 — חוזה כלי-ה-MCP - **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1–TOOL5 · **effort:** L · **תלויות:** FU-1 - **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT +- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה). נותר: GAP-45 (status-tool), GAP-48 (envelope), GAP-49/50 (מיזוג+rename — שובר), GAP-51 (set_outcome enum SSoT), GAP-52 (idempotency). ### FU-15 — deploy/env/secrets - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1–ENV5 · **effort:** M · **תלויות:** — diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index fd419f8..3852fe4 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -84,10 +84,24 @@ async def case_create( ) +# INV-TOOL5 / GAP-53: hard cap on list/search result sizes (OWASP API4:2023 — +# Unrestricted Resource Consumption). Non-positive is treated as "max", not "all". +_MAX_LIMIT = 200 + + +def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int: + """Clamp a caller-supplied result limit to [1, hard_max].""" + try: + n = int(limit) + except (TypeError, ValueError): + return hard_max + return hard_max if n <= 0 else min(n, hard_max) + + @mcp.tool() async def case_list(status: str = "", limit: int = 50) -> str: """רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final).""" - return await cases.case_list(status, limit) + return await cases.case_list(status, _clamp_limit(limit)) @mcp.tool() @@ -162,7 +176,7 @@ async def precedent_search_library( ) -> str: """חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents). שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית.""" - return await precedents.precedent_search_library(query, practice_area, limit) + return await precedents.precedent_search_library(query, practice_area, _clamp_limit(limit)) # ── External Precedent Library — authoritative case-law corpus ───── @@ -214,7 +228,7 @@ async def precedent_library_list( """ return await plib.precedent_library_list( practice_area, court, precedent_level, source_type, search, - source_kind, limit, + source_kind, _clamp_limit(limit), ) @@ -273,7 +287,7 @@ async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str: @mcp.tool() async def style_corpus_pending_enrichment(limit: int = 50) -> str: """רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ.""" - return await train_tools.list_corpus_pending_enrichment(limit) + return await train_tools.list_corpus_pending_enrichment(_clamp_limit(limit)) @mcp.tool() @@ -296,7 +310,7 @@ async def search_precedent_library( """חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation).""" return await plib.search_precedent_library( query, practice_area, court, precedent_level, appeal_subtype, - None, subject_tag, limit, include_halachot, + None, subject_tag, _clamp_limit(limit), include_halachot, ) @@ -320,7 +334,7 @@ async def halacha_review( @mcp.tool() async def halachot_pending(limit: int = 100) -> str: """תור ההלכות הממתינות לאישור.""" - return await plib.halachot_pending(limit) + return await plib.halachot_pending(_clamp_limit(limit)) # Documents @@ -439,7 +453,7 @@ async def search_decisions( ) -> str: """חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.""" return await search.search_decisions( - query, limit, section_type, practice_area, appeal_subtype, case_number, + query, _clamp_limit(limit), section_type, practice_area, appeal_subtype, case_number, ) @@ -450,7 +464,7 @@ async def search_case_documents( limit: int = 10, ) -> str: """חיפוש סמנטי בתוך מסמכי תיק ספציפי.""" - return await search.search_case_documents(case_number, query, limit) + return await search.search_case_documents(case_number, query, _clamp_limit(limit)) @mcp.tool() @@ -463,7 +477,7 @@ async def find_similar_cases( ) -> str: """מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.""" return await search.find_similar_cases( - description, limit, practice_area, appeal_subtype, case_number, + description, _clamp_limit(limit), practice_area, appeal_subtype, case_number, ) @@ -496,7 +510,7 @@ async def search_internal_decisions( כשרוצים להרחיב מעבר לטקסט המקורי. default False. """ return await search.search_internal_decisions( - query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot, + query, practice_area, appeal_subtype, district, chair_name, _clamp_limit(limit), include_halachot, include_cited_by=include_cited_by, ) @@ -571,6 +585,12 @@ async def extract_appraiser_facts(case_number: str) -> str: return await drafting.extract_appraiser_facts(case_number) +@mcp.tool() +async def get_appraiser_facts(case_number: str) -> str: + """קריאת עובדות-השמאי שכבר חולצו (facts + סתירות) — ללא חילוץ-מחדש יקר. ה-get המקביל ל-extract_appraiser_facts.""" + return await drafting.get_appraiser_facts(case_number) + + @mcp.tool() async def write_interim_draft(case_number: str, instructions: str = "") -> str: """כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט.""" @@ -813,7 +833,7 @@ async def missing_precedent_list( case_number=case_number, status=status, legal_topic=legal_topic, - limit=limit, + limit=_clamp_limit(limit), ) @@ -878,7 +898,7 @@ async def list_internal_citations( return await cit_tools.list_internal_citations( case_law_id=case_law_id, linked_only=linked_only, - limit=limit, + limit=_clamp_limit(limit), ) @@ -894,7 +914,7 @@ async def list_incoming_citations( """ return await cit_tools.list_incoming_citations( case_law_id=case_law_id, - limit=limit, + limit=_clamp_limit(limit), ) @@ -917,9 +937,10 @@ async def list_chair_feedback( case_number: str = "", category: str = "", unresolved_only: bool = True, + limit: int = 100, ) -> str: """הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות.""" - return await workflow.list_chair_feedback(case_number, category, unresolved_only) + return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit)) @mcp.tool() diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index fcc96c9..568c9eb 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2428,8 +2428,9 @@ async def list_chair_feedback( case_id: UUID | None = None, category: str | None = None, unresolved_only: bool = False, + limit: int = 100, ) -> list[dict]: - """List chair feedback, optionally filtered.""" + """List chair feedback, optionally filtered. Capped by limit (INV-TOOL5 / GAP-53).""" pool = await get_pool() conditions = [] params: list = [] @@ -2447,9 +2448,10 @@ async def list_chair_feedback( conditions.append("resolved = FALSE") where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params.append(max(1, int(limit))) async with pool.acquire() as conn: rows = await conn.fetch( - f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC", + f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC LIMIT ${idx}", *params, ) return [dict(r) for r in rows] diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index ac28015..2eef4ac 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -477,6 +477,37 @@ async def extract_appraiser_facts(case_number: str) -> str: ensure_ascii=False, indent=2) +async def get_appraiser_facts(case_number: str) -> str: + """קריאת עובדות-השמאי שכבר חולצו לתיק — ללא הרצת חילוץ מחדש (INV-TOOL4 / GAP-44). + + ה-get המקביל ל-extract_appraiser_facts: מחזיר את העובדות השמורות בטבלת + appraiser_facts + סתירות מזוהות בין שמאים, בלי קריאת-LLM יקרה ולא-דטרמיניסטית. + מחזיר facts ריק אם החילוץ טרם רץ (status=ok, count=0) — לא שגיאה. + + Args: + case_number: מספר תיק הערר + """ + case = await db.get_case_by_number(case_number) + if not case: + return json.dumps({"status": "error", + "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2) + case_id = UUID(case["id"]) + try: + facts = await db.list_appraiser_facts(case_id) + conflicts = await db.detect_appraiser_conflicts(case_id) + return json.dumps({ + "status": "ok", + "case_number": case_number, + "count": len(facts), + "facts": facts, + "conflicts": conflicts, + }, default=str, ensure_ascii=False, indent=2) + except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, + ensure_ascii=False, indent=2) + + async def write_interim_draft(case_number: str, instructions: str = "") -> str: """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index e8f2e29..3005d59 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -394,6 +394,7 @@ async def list_chair_feedback( case_number: str = "", category: str = "", unresolved_only: bool = True, + limit: int = 100, ) -> str: """הצגת הערות יו"ר שתועדו, עם אפשרות סינון. @@ -401,6 +402,7 @@ async def list_chair_feedback( case_number: סינון לפי תיק (אם ריק — כל ההערות) category: סינון לפי קטגוריה unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן) + limit: תקרת תוצאות (INV-TOOL5 / GAP-53) """ case_id = None if case_number: @@ -412,6 +414,7 @@ async def list_chair_feedback( case_id=case_id, category=category or None, unresolved_only=unresolved_only, + limit=limit, ) if not feedbacks: