feat(scripts): כפתור "הרץ" מ-UI לסקריפטי read-only (קטגוריה B #4) #283
@@ -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
|
||||
|
||||
|
||||
|
||||
50
mcp-server/src/legal_mcp/services/script_runner.py
Normal file
50
mcp-server/src/legal_mcp/services/script_runner.py
Normal file
@@ -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:]]
|
||||
@@ -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<string>;
|
||||
runningName: string | null;
|
||||
onRun: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -195,6 +213,23 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
|
||||
<StatusChip status={s.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-3.5 text-end">
|
||||
<span className="inline-flex items-center gap-2 justify-end">
|
||||
{/* "הרץ" — only on read-only/audit scripts in the host allowlist (#4) */}
|
||||
{!disabled && runnable.has(s.name) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={runningName !== null}
|
||||
onClick={() => onRun(s.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-success bg-success px-3.5 py-1.5 text-[0.81rem] font-semibold text-white hover:bg-success/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{runningName === s.name ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Play className="size-3.5" aria-hidden />
|
||||
)}
|
||||
הרץ
|
||||
</button>
|
||||
) : null}
|
||||
{disabled || !href ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -213,6 +248,7 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
|
||||
מקור
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -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<string>;
|
||||
runningName: string | null;
|
||||
onRun: (name: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
|
||||
@@ -248,7 +292,15 @@ function ScriptGroup({
|
||||
{rows.length}
|
||||
</span>
|
||||
</button>
|
||||
{open ? <ScriptTable rows={rows} giteaBase={giteaBase} /> : null}
|
||||
{open ? (
|
||||
<ScriptTable
|
||||
rows={rows}
|
||||
giteaBase={giteaBase}
|
||||
runnable={runnable}
|
||||
runningName={runningName}
|
||||
onRun={onRun}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* run-output dialog (#4) — opens when a run completes (exit code + output) */}
|
||||
<Dialog
|
||||
open={!!runState && !runState.running}
|
||||
onOpenChange={(o) => !o && setRunState(null)}
|
||||
>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-ink-soft text-sm font-normal">הרצה:</span>
|
||||
<code dir="ltr" className="font-mono text-sm text-navy">
|
||||
{runState?.name}
|
||||
</code>
|
||||
{runState?.result ? (
|
||||
<span
|
||||
className={`ms-auto rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
runState.result.ok
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-danger-bg text-danger"
|
||||
}`}
|
||||
>
|
||||
{runState.result.ok ? "✓" : "✗"} exit {runState.result.exit_code}
|
||||
</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{runState?.error ? (
|
||||
<p className="text-danger text-sm">{runState.error}</p>
|
||||
) : runState?.result ? (
|
||||
<pre
|
||||
dir="ltr"
|
||||
className="text-[0.72rem] font-mono whitespace-pre-wrap max-h-72 overflow-auto rounded-md border border-rule-soft bg-parchment p-3 leading-relaxed"
|
||||
>
|
||||
{(runState.result.stdout || "") +
|
||||
(runState.result.stderr ? `\n${runState.result.stderr}` : "") ||
|
||||
"—"}
|
||||
</pre>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ScriptsCatalog>("/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<ScriptRunResult>(
|
||||
`/api/scripts/${encodeURIComponent(name)}/run`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
34
web/app.py
34
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:
|
||||
|
||||
Reference in New Issue
Block a user