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.court_fetch_service import camofox_client # noqa: E402
|
||||||
from legal_mcp.services import usage_limits # 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")
|
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:
|
def build_app() -> web.Application:
|
||||||
app = web.Application(client_max_size=64 * 1024 * 1024)
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
app.router.add_get("/health", health)
|
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("/pm2/control", pm2_control)
|
||||||
app.router.add_post("/fetch", fetch)
|
app.router.add_post("/fetch", fetch)
|
||||||
app.router.add_post("/adapter-migration", adapter_migration)
|
app.router.add_post("/adapter-migration", adapter_migration)
|
||||||
|
app.router.add_post("/run-script", run_script)
|
||||||
return app
|
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 { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { AppShell } from "@/components/app-shell";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,7 +21,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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
|
* /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 (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -195,6 +213,23 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
|
|||||||
<StatusChip status={s.status} />
|
<StatusChip status={s.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-5 py-3.5 text-end">
|
<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 ? (
|
{disabled || !href ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -213,6 +248,7 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
|
|||||||
מקור
|
מקור
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -225,8 +261,16 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
|
|||||||
/** Collapsible sub-topic block (#11, mockup 16): parchment header with a
|
/** Collapsible sub-topic block (#11, mockup 16): parchment header with a
|
||||||
* chevron + title + count, and the group's table beneath. */
|
* chevron + title + count, and the group's table beneath. */
|
||||||
function ScriptGroup({
|
function ScriptGroup({
|
||||||
title, rows, giteaBase, defaultOpen,
|
title, rows, giteaBase, defaultOpen, runnable, runningName, onRun,
|
||||||
}: { title: string; rows: ScriptRow[]; giteaBase: string | null; defaultOpen: boolean }) {
|
}: {
|
||||||
|
title: string;
|
||||||
|
rows: ScriptRow[];
|
||||||
|
giteaBase: string | null;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
runnable: Set<string>;
|
||||||
|
runningName: string | null;
|
||||||
|
onRun: (name: string) => void;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
return (
|
return (
|
||||||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
|
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
|
||||||
@@ -248,7 +292,15 @@ function ScriptGroup({
|
|||||||
{rows.length}
|
{rows.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{open ? <ScriptTable rows={rows} giteaBase={giteaBase} /> : null}
|
{open ? (
|
||||||
|
<ScriptTable
|
||||||
|
rows={rows}
|
||||||
|
giteaBase={giteaBase}
|
||||||
|
runnable={runnable}
|
||||||
|
runningName={runningName}
|
||||||
|
onRun={onRun}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,6 +328,36 @@ export default function ScriptsPage() {
|
|||||||
|
|
||||||
const giteaBase = data?.gitea_url ?? null;
|
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 (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@@ -314,6 +396,9 @@ export default function ScriptsPage() {
|
|||||||
rows={g.rows}
|
rows={g.rows}
|
||||||
giteaBase={giteaBase}
|
giteaBase={giteaBase}
|
||||||
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
|
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
|
||||||
|
runnable={runnable}
|
||||||
|
runningName={runningName}
|
||||||
|
onRun={handleRun}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{lastModified ? (
|
{lastModified ? (
|
||||||
@@ -324,6 +409,46 @@ export default function ScriptsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "./client";
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
// ── Scripts catalog ───────────────────────────────────────────────
|
// ── Scripts catalog ───────────────────────────────────────────────
|
||||||
@@ -11,8 +12,32 @@ export type ScriptsCatalog = {
|
|||||||
bytes: number;
|
bytes: number;
|
||||||
last_modified: number;
|
last_modified: number;
|
||||||
gitea_url: string;
|
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) {
|
export function fetchScriptsCatalog(signal?: AbortSignal) {
|
||||||
return apiRequest<ScriptsCatalog>("/api/scripts/catalog", { signal });
|
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
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
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 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
|
from legal_mcp.tools.envelope import envelope_unwrap
|
||||||
|
|
||||||
@@ -1286,6 +1286,8 @@ async def get_scripts_catalog():
|
|||||||
"bytes": stat.st_size,
|
"bytes": stat.st_size,
|
||||||
"last_modified": stat.st_mtime,
|
"last_modified": stat.st_mtime,
|
||||||
"gitea_url": gitea_url,
|
"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
|
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}")
|
@app.get("/api/digests/{digest_id}")
|
||||||
async def digest_get(digest_id: str):
|
async def digest_get(digest_id: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user