feat(scripts): כפתור "הרץ" מ-UI לסקריפטי read-only (קטגוריה B #4) #283

Merged
chaim merged 1 commits from worktree-scripts-run-ui into main 2026-06-17 04:31:20 +00:00
5 changed files with 315 additions and 25 deletions
Showing only changes of commit 221975fe23 - Show all commits

View File

@@ -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

View 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:]]

View File

@@ -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,24 +213,42 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
<StatusChip status={s.status} />
</TableCell>
<TableCell className="px-5 py-3.5 text-end">
{disabled || !href ? (
<button
type="button"
disabled
className="rounded-lg border border-rule-soft px-4 py-1.5 text-[0.81rem] font-semibold text-ink-muted cursor-default"
>
מקור
</button>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg border border-rule px-4 py-1.5 text-[0.81rem] font-semibold text-gold-deep hover:bg-gold-wash hover:border-gold transition-colors"
>
מקור
</a>
)}
<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"
disabled
className="rounded-lg border border-rule-soft px-4 py-1.5 text-[0.81rem] font-semibold text-ink-muted cursor-default"
>
מקור
</button>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg border border-rule px-4 py-1.5 text-[0.81rem] font-semibold text-gold-deep hover:bg-gold-wash hover:border-gold transition-colors"
>
מקור
</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>
);
}

View File

@@ -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" },
),
});
}

View File

@@ -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: