diff --git a/mcp-server/src/legal_mcp/court_fetch_service/server.py b/mcp-server/src/legal_mcp/court_fetch_service/server.py index bf11396..3a79cea 100644 --- a/mcp-server/src/legal_mcp/court_fetch_service/server.py +++ b/mcp-server/src/legal_mcp/court_fetch_service/server.py @@ -44,6 +44,7 @@ if _pkg_root not in sys.path: from legal_mcp.court_fetch_service import camofox_client # noqa: E402 from legal_mcp.services import usage_limits # noqa: E402 +from legal_mcp.services import script_runner # noqa: E402 logger = logging.getLogger("legal_court_fetch_service") @@ -342,6 +343,62 @@ async def adapter_migration(request: web.Request) -> web.Response: }) +# ─── run-script: host-side runner for read-only/audit scripts (#4) ───────────── +# Same shape as /adapter-migration but for the SCRIPT_RUN_ALLOWLIST — a fixed set +# of read-only scripts each with a hard-coded safe argv. The request body's only +# meaningful field is ``name``; arguments are NEVER taken from the caller (so no +# --apply/--force injection). Allowlist enforcement lives here, on the host. +async def run_script(request: web.Request) -> web.Response: + """Run an allowlisted read-only script on the host and relay its result.""" + unauth = _check_bearer(request) + if unauth is not None: + return unauth + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid JSON body"}, status=400) + + name = str(body.get("name", "")).strip() + argv = script_runner.build_argv(name) + if argv is None: + return web.json_response( + {"ok": False, "error": f"script not runnable (not in allowlist): {name!r}"}, + status=403, + ) + + import asyncio as _asyncio + + env = {**os.environ, "HOME": "/home/chaim"} + try: + proc = await _asyncio.create_subprocess_exec( + *argv, cwd="/home/chaim/legal-ai", env=env, + stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE, + ) + out, err = await _asyncio.wait_for(proc.communicate(), timeout=600) + except _asyncio.TimeoutError: + return web.json_response({"ok": False, "error": "script timed out"}, status=504) + except Exception as e: # never throw — relay the failure + return web.json_response({"ok": False, "error": f"launch failed: {e}"}, status=502) + + # best-effort audit trail — one line per run + try: + os.makedirs("/home/chaim/legal-ai/data/logs", exist_ok=True) + stamp = time.strftime("%Y-%m-%dT%H:%M:%S%z") + with open("/home/chaim/legal-ai/data/logs/script-runs.log", "a") as fh: + fh.write(f"{stamp}\t{name}\texit={proc.returncode}\n") + except Exception: + pass + + # 200 regardless of exit code — a non-zero audit result is informative output + # the caller renders, not a transport error. + return web.json_response({ + "ok": (proc.returncode == 0), + "exit_code": proc.returncode, + "stdout": out.decode("utf-8", "replace"), + "stderr": err.decode("utf-8", "replace"), + }) + + def build_app() -> web.Application: app = web.Application(client_max_size=64 * 1024 * 1024) app.router.add_get("/health", health) @@ -350,6 +407,7 @@ def build_app() -> web.Application: app.router.add_post("/pm2/control", pm2_control) app.router.add_post("/fetch", fetch) app.router.add_post("/adapter-migration", adapter_migration) + app.router.add_post("/run-script", run_script) return app diff --git a/mcp-server/src/legal_mcp/services/script_runner.py b/mcp-server/src/legal_mcp/services/script_runner.py new file mode 100644 index 0000000..327c1e3 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/script_runner.py @@ -0,0 +1,50 @@ +"""Allowlist of read-only scripts runnable from the /scripts page (#4). + +Single source of truth shared by BOTH: + - the host bridge (``court_fetch_service.server`` — the ENFORCER that actually + launches the process), and + - the container API (``web/app.py`` — display-only: tells the UI which rows get + a "הרץ" button). + +Each entry maps a script's basename to the EXACT, fixed argument list it runs +with. **Read-only / audit scripts only**, with a safe fixed argv and **no +user-supplied arguments** — never ``--apply``/``--force``. The system is +single-user/internal, so this allowlist is a footgun-guard, not an auth boundary; +enforcement lives on the trusted host side. + +Adding an entry is a security decision: verify the script is read-only (no DB +writes, no destructive side effects) with the given argv before listing it. + +stdlib-only on purpose, so the lightweight host bridge can import it cheaply. +""" + +from __future__ import annotations + +_REPO = "/home/chaim/legal-ai" +_PYTHON = f"{_REPO}/mcp-server/.venv/bin/python" + +# basename → argv tail (script path relative to the repo root, then fixed flags). +# Verified read-only 2026-06-17. `audit_corpus_integrity` runs with --no-notify +# so it stays report-only (no notification side-effect) when run from the dashboard. +SCRIPT_RUN_ALLOWLIST: dict[str, list[str]] = { + "leak_guard.py": ["scripts/leak_guard.py"], + "check_undefined_names.py": ["scripts/check_undefined_names.py"], + "storage_leak_tripwire.py": ["scripts/storage_leak_tripwire.py"], + "audit_training_corpus.py": ["scripts/audit_training_corpus.py"], + "audit_corpus_integrity.py": ["scripts/audit_corpus_integrity.py", "--no-notify"], +} + + +def runnable_names() -> list[str]: + """Sorted basenames the UI may show a "הרץ" button for (display-only).""" + return sorted(SCRIPT_RUN_ALLOWLIST) + + +def build_argv(name: str) -> list[str] | None: + """Full argv (python + absolute script path + fixed flags) for an allowlisted + script, or ``None`` when *name* is not allowlisted. Arguments are taken ONLY + from the allowlist — anything the caller passes is ignored.""" + tail = SCRIPT_RUN_ALLOWLIST.get(name) + if tail is None: + return None + return [_PYTHON, f"{_REPO}/{tail[0]}", *tail[1:]] diff --git a/web-ui/src/app/scripts/page.tsx b/web-ui/src/app/scripts/page.tsx index 07747de..b033c57 100644 --- a/web-ui/src/app/scripts/page.tsx +++ b/web-ui/src/app/scripts/page.tsx @@ -3,10 +3,16 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { ChevronDown, ChevronLeft } from "lucide-react"; +import { ChevronDown, ChevronLeft, Play, Loader2 } from "lucide-react"; import { AppShell } from "@/components/app-shell"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Table, TableBody, @@ -15,7 +21,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { fetchScriptsCatalog } from "@/lib/api/scripts"; +import { + fetchScriptsCatalog, + useRunScript, + type ScriptRunResult, +} from "@/lib/api/scripts"; /* * /scripts — catalog of everything under scripts/, rendered as the @@ -150,7 +160,15 @@ function StatusChip({ status }: { status: ScriptStatus }) { ); } -function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string | null }) { +function ScriptTable({ + rows, giteaBase, runnable, runningName, onRun, +}: { + rows: ScriptRow[]; + giteaBase: string | null; + runnable: Set; + runningName: string | null; + onRun: (name: string) => void; +}) { return ( @@ -195,24 +213,42 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string - {disabled || !href ? ( - - ) : ( - - מקור - - )} + + {/* "הרץ" — only on read-only/audit scripts in the host allowlist (#4) */} + {!disabled && runnable.has(s.name) ? ( + + ) : null} + {disabled || !href ? ( + + ) : ( + + מקור + + )} + ); @@ -225,8 +261,16 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string /** Collapsible sub-topic block (#11, mockup 16): parchment header with a * chevron + title + count, and the group's table beneath. */ function ScriptGroup({ - title, rows, giteaBase, defaultOpen, -}: { title: string; rows: ScriptRow[]; giteaBase: string | null; defaultOpen: boolean }) { + title, rows, giteaBase, defaultOpen, runnable, runningName, onRun, +}: { + title: string; + rows: ScriptRow[]; + giteaBase: string | null; + defaultOpen: boolean; + runnable: Set; + runningName: string | null; + onRun: (name: string) => void; +}) { const [open, setOpen] = useState(defaultOpen); return ( @@ -248,7 +292,15 @@ function ScriptGroup({ {rows.length} - {open ? : null} + {open ? ( + + ) : null} ); } @@ -276,6 +328,36 @@ export default function ScriptsPage() { const giteaBase = data?.gitea_url ?? null; + // #4 — read-only scripts the host allowlist permits running; the "הרץ" button + // surfaces only for these. A run opens an output dialog with exit code + stdout. + const runnable = useMemo( + () => new Set(data?.runnable_scripts ?? []), + [data], + ); + const run = useRunScript(); + const [runState, setRunState] = useState<{ + name: string; + running: boolean; + result?: ScriptRunResult; + error?: string; + } | null>(null); + const runningName = runState?.running ? runState.name : null; + + async function handleRun(name: string) { + if (!window.confirm(`להריץ את ${name}? (סקריפט קריאה-בלבד)`)) return; + setRunState({ name, running: true }); + try { + const result = await run.mutateAsync(name); + setRunState({ name, running: false, result }); + } catch (e) { + setRunState({ + name, + running: false, + error: e instanceof Error ? e.message : "שגיאה בהרצה", + }); + } + } + return (
@@ -314,6 +396,9 @@ export default function ScriptsPage() { rows={g.rows} giteaBase={giteaBase} defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP} + runnable={runnable} + runningName={runningName} + onRun={handleRun} /> ))} {lastModified ? ( @@ -324,6 +409,46 @@ export default function ScriptsPage() { )}
+ + {/* run-output dialog (#4) — opens when a run completes (exit code + output) */} + !o && setRunState(null)} + > + + + + הרצה: + + {runState?.name} + + {runState?.result ? ( + + {runState.result.ok ? "✓" : "✗"} exit {runState.result.exit_code} + + ) : null} + + + {runState?.error ? ( +

{runState.error}

+ ) : runState?.result ? ( +
+              {(runState.result.stdout || "") +
+                (runState.result.stderr ? `\n${runState.result.stderr}` : "") ||
+                "—"}
+            
+ ) : null} +
+
); } diff --git a/web-ui/src/lib/api/scripts.ts b/web-ui/src/lib/api/scripts.ts index 0c3008e..11b876e 100644 --- a/web-ui/src/lib/api/scripts.ts +++ b/web-ui/src/lib/api/scripts.ts @@ -1,3 +1,4 @@ +import { useMutation } from "@tanstack/react-query"; import { apiRequest } from "./client"; // ── Scripts catalog ─────────────────────────────────────────────── @@ -11,8 +12,32 @@ export type ScriptsCatalog = { bytes: number; last_modified: number; gitea_url: string; + // #4 — basenames the UI may offer a "הרץ" button for (read-only allowlist, + // enforced host-side). Optional so an older backend (pre-deploy) degrades to + // "מקור"-only with no run buttons. + runnable_scripts?: string[]; }; export function fetchScriptsCatalog(signal?: AbortSignal) { return apiRequest("/api/scripts/catalog", { signal }); } + +// ── Run a read-only script (#4) ─────────────────────────────────── +// Proxies to the court-fetch host bridge; only allowlisted read-only scripts +// run, with a fixed safe argv. Exit code + captured output are relayed verbatim. +export type ScriptRunResult = { + ok: boolean; + exit_code: number; + stdout: string; + stderr: string; +}; + +export function useRunScript() { + return useMutation({ + mutationFn: (name: string) => + apiRequest( + `/api/scripts/${encodeURIComponent(name)}/run`, + { method: "POST" }, + ), + }); +} diff --git a/web/app.py b/web/app.py index f800331..f476cf8 100644 --- a/web/app.py +++ b/web/app.py @@ -31,7 +31,7 @@ import asyncpg import httpx from legal_mcp import config -from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md, storage +from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md, script_runner, storage from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools from legal_mcp.tools.envelope import envelope_unwrap @@ -1286,6 +1286,8 @@ async def get_scripts_catalog(): "bytes": stat.st_size, "last_modified": stat.st_mtime, "gitea_url": gitea_url, + # #4 — basenames the UI may offer a "הרץ" button for (read-only allowlist). + "runnable_scripts": script_runner.runnable_names(), } @@ -6966,6 +6968,36 @@ async def operations_agent_migrate_adapter(req: AdapterMigrationRequest): return payload +@app.post("/api/scripts/{name}/run") +async def scripts_run(name: str): + """Run a read-only allowlisted script via the court-fetch host bridge (#4). + + Only scripts in ``script_runner.SCRIPT_RUN_ALLOWLIST`` are runnable; the host + bridge is the enforcer (allowlist + fixed read-only argv, no args from here). + The script's exit code + stdout/stderr are relayed verbatim for the dashboard. + We pre-check the allowlist here too (defense-in-depth, avoids a useless round + trip). Audit scripts can take a while, so the timeout is generous.""" + if script_runner.build_argv(name) is None: + raise HTTPException(403, f"סקריפט אינו ניתן-להרצה מה-UI: {name}") + secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip() + headers = {"Authorization": f"Bearer {secret}"} if secret else {} + try: + async with httpx.AsyncClient(timeout=610.0) as client: + r = await client.post( + f"{_COURT_FETCH_SERVICE_URL}/run-script", + json={"name": name}, headers=headers, + ) + except Exception as e: # host bridge down / unreachable + raise HTTPException(502, f"לא ניתן להגיע לשירות-המארח: {e}") from e + try: + payload = r.json() + except Exception: + payload = {"ok": False, "error": r.text[:300]} + if r.status_code >= 400: + raise HTTPException(r.status_code, payload.get("error", "script bridge failed")) + return payload + + @app.get("/api/digests/{digest_id}") async def digest_get(digest_id: str): try: