GAP-57 (אבטחה, CWE-798 / INV-ENV4): ה-default הקשיח postgresql://paperclip:paperclip@... הוסר מ-3 קבצי web/. נוסף resolver משותף require_paperclip_db_url() ב-paperclip_api.py שנכשל בקול אם PAPERCLIP_DB_URL לא מוגדר — במקום ליפול בשקט ל-creds ידועים. Coolify מגדיר את המשתנה (אומת), אז הייצור לא נפגע. (2 מופעים בסקריפטים מקומיים נותרו ל-FU-15 המלא.) FU-13 (INV-AG3, GAP-46): יישור הרשאות-סוכן. התברר שהפער שמופה ב-31.5 היה רחב מדי — יוחס לפי תיאור-תפקיד, לא ההוראות בפועל. הכרעת-יו"ר "היבריד": - legal-analyst: נוסף aggregate_claims_to_arguments (frontmatter + שלב 7) — הכלי שמקבץ את הטענות שהוא חילץ לטיעונים משפטיים. - extract_references/extract_internal_citations הם מטלת-researcher (שכבר מחזיק אותם), לא analyst — הוסרו מרשימת "החסרים". - legal-researcher: כבר היה תקין; ה-spec היה מיושן. עודכנו X4-agents.md (§2א, INV-AG3) ו-gap-audit.md (FU-13 ✅, FU-15 חלקי). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
7.4 KiB
Python
224 lines
7.4 KiB
Python
"""Paperclip REST API helper.
|
|
|
|
All HTTP calls from legal-ai backend (FastAPI) to Paperclip should go through
|
|
``pc_request`` so that auth + audit headers are applied consistently.
|
|
|
|
The bash counterpart for agents lives at ``scripts/pc.sh``.
|
|
|
|
Notes
|
|
-----
|
|
* Uses ``PAPERCLIP_BOARD_API_KEY`` (long-lived) — these are *board* actions
|
|
(wakeups, comments-as-user) initiated from outside a heartbeat, not agent
|
|
actions. Board API keys are not JWTs, so they do **not** carry a ``run_id``
|
|
claim; pass ``run_id=`` explicitly when you have one (rare for board flows).
|
|
* For agent actions inside a heartbeat run, agents use the bash helper with
|
|
the auto-injected ``PAPERCLIP_API_KEY`` JWT — those carry ``run_id`` in
|
|
claims, so the header is informational/future-proofing.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
|
|
|
DEFAULT_TIMEOUT = 15.0
|
|
|
|
|
|
def require_paperclip_db_url() -> str:
|
|
"""Return ``PAPERCLIP_DB_URL`` or fail loud — never fall back to a default.
|
|
|
|
INV-ENV4 / GAP-57: a credential must not live in source as a default value.
|
|
The Coolify container and local tooling supply this explicitly; its absence
|
|
is a misconfiguration we surface immediately rather than silently connecting
|
|
with a well-known default credential (CWE-798). Mirrors the fail-loud guard
|
|
on ``PAPERCLIP_BOARD_API_KEY`` in ``_build_headers``.
|
|
"""
|
|
url = os.environ.get("PAPERCLIP_DB_URL", "")
|
|
if not url:
|
|
raise RuntimeError(
|
|
"PAPERCLIP_DB_URL not set — refusing hard-coded credential fallback "
|
|
"(INV-ENV4 / GAP-57). Set PAPERCLIP_DB_URL in the environment "
|
|
"(Coolify for the container, or your shell/.env for local tooling)."
|
|
)
|
|
return url
|
|
|
|
|
|
def _build_headers(run_id: str | None, has_body: bool) -> dict[str, str]:
|
|
if not PAPERCLIP_BOARD_API_KEY:
|
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot call Paperclip API")
|
|
headers = {
|
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
|
"X-Paperclip-Run-Id": run_id or "",
|
|
}
|
|
if has_body:
|
|
headers["Content-Type"] = "application/json"
|
|
return headers
|
|
|
|
|
|
async def pc_request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json: dict[str, Any] | None = None,
|
|
run_id: str | None = None,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
raise_on_error: bool = False,
|
|
) -> httpx.Response:
|
|
"""Make a Paperclip REST request.
|
|
|
|
Parameters
|
|
----------
|
|
method : str
|
|
HTTP method (GET, POST, PATCH, DELETE).
|
|
path : str
|
|
Path relative to PAPERCLIP_API_URL (must start with ``/``).
|
|
json : dict, optional
|
|
Request body — sent as JSON.
|
|
run_id : str, optional
|
|
Heartbeat run ID for audit trail (X-Paperclip-Run-Id header).
|
|
Rare for board actions; provide when initiating from inside a run.
|
|
timeout : float
|
|
httpx timeout (default 15s).
|
|
raise_on_error : bool
|
|
If True, calls ``response.raise_for_status()`` on 4xx/5xx.
|
|
|
|
Returns
|
|
-------
|
|
httpx.Response
|
|
"""
|
|
headers = _build_headers(run_id, has_body=json is not None)
|
|
url = f"{PAPERCLIP_API_URL}{path}"
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
resp = await client.request(method, url, headers=headers, json=json)
|
|
if raise_on_error:
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
|
|
async def emit_case_status_webhook(
|
|
case_number: str,
|
|
old_status: str,
|
|
new_status: str,
|
|
company_id: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
"""Notify the Paperclip plugin that a case status changed.
|
|
|
|
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
|
"""
|
|
try:
|
|
await pc_request(
|
|
"POST",
|
|
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
|
json={
|
|
"eventType": "status_change",
|
|
"caseNumber": case_number,
|
|
"oldStatus": old_status,
|
|
"newStatus": new_status,
|
|
"companyId": company_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
run_id=run_id,
|
|
timeout=5.0,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"emit_case_status_webhook failed for case %s (%s → %s): %s",
|
|
case_number, old_status, new_status, exc,
|
|
)
|
|
|
|
|
|
async def emit_missing_precedent_webhook(
|
|
*,
|
|
case_number: str,
|
|
missing_precedent_id: str,
|
|
citation: str,
|
|
cited_by_party: str | None = None,
|
|
cited_by_party_name: str | None = None,
|
|
legal_topic: str | None = None,
|
|
legal_issue: str | None = None,
|
|
company_id: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
"""Tell the plugin that a missing precedent was logged for a case.
|
|
|
|
The plugin uses this to surface an ``askUserQuestions`` interaction
|
|
on the linked Paperclip issue so the chair can decide whether to
|
|
upload the cited precedent or mark it irrelevant.
|
|
|
|
Fire-and-forget.
|
|
"""
|
|
try:
|
|
await pc_request(
|
|
"POST",
|
|
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
|
json={
|
|
"eventType": "missing_precedent_created",
|
|
"caseNumber": case_number,
|
|
"companyId": company_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"missingPrecedent": {
|
|
"id": missing_precedent_id,
|
|
"citation": citation,
|
|
"citedByParty": cited_by_party,
|
|
"citedByPartyName": cited_by_party_name,
|
|
"legalTopic": legal_topic,
|
|
"legalIssue": legal_issue,
|
|
},
|
|
},
|
|
run_id=run_id,
|
|
timeout=5.0,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"emit_missing_precedent_webhook failed for case %s (%s): %s",
|
|
case_number, citation, exc,
|
|
)
|
|
|
|
|
|
async def emit_export_complete_webhook(
|
|
*,
|
|
case_number: str,
|
|
docx_filename: str,
|
|
docx_title: str | None = None,
|
|
company_id: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
"""Tell the plugin that a final DOCX was exported for a case.
|
|
|
|
The plugin uses this to attach a "final decision" document to the
|
|
linked Paperclip issue (markdown body with a download link to the
|
|
DOCX). Binary attachment is intentionally avoided — the SDK's
|
|
``documents.upsert`` accepts text only.
|
|
|
|
Fire-and-forget.
|
|
"""
|
|
try:
|
|
await pc_request(
|
|
"POST",
|
|
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
|
json={
|
|
"eventType": "export_complete",
|
|
"caseNumber": case_number,
|
|
"companyId": company_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"docxFilename": docx_filename,
|
|
"docxTitle": docx_title or f"החלטה סופית — {case_number}",
|
|
},
|
|
run_id=run_id,
|
|
timeout=5.0,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"emit_export_complete_webhook failed for case %s (%s): %s",
|
|
case_number, docx_filename, exc,
|
|
)
|