Files
NexusOS/synapse/code_run.py
T
AthenaandClaude Sonnet 5 dd3ce09feb fix(windows): real toolchain probing, HOME/TEMP env, and gate portability
code_run.py: shutil.which() finding a compiler executable on PATH doesn't
mean it's a usable toolchain on Windows -- rustc's MSVC target also needs
Microsoft's linker, and an MSYS2 gcc/clang driver can remain resolvable after
one of its runtime DLLs has broken. Both cases silently turned every C/C++/
Rust snippet into a compile error while the capability check said "ready".
_compiled_tool() now actually compiles+links a trivial known-good program
per candidate (Windows only; POSIX keeps the cheap which(1) check since
release hosts install compiler packages atomically) and caches the result.

Also fixes the run/compile child environment: HOME/TMPDIR don't control
Windows' real temp/profile resolution (expanduser() reaches the actual user
profile, GetTempPath() falls back to the Windows directory), letting a
snippet escape the scratch directory or fail outright. _child_env() now also
sets TEMP/TMP/USERPROFILE on Windows.

tests/conftest.py (new): isolates curry_store's SQLite singleton into a
per-run temp directory via NEXUS_CURRY_DB before any test module imports
synapse, and cleans it up at session end -- the release gate no longer
writes test constants into the checkout's live data/curry.db. .gitignore
picks up /data/curry.db for whatever still lands there locally.

bin/check.sh: falls back to Promethean/Scripts/python.exe when
Promethean/bin/python doesn't exist, so the gate actually runs on a Windows
venv instead of immediately exiting "no Promethean venv".

Verified independently: 254 passed, 0 failed, 8 skipped (tests + management)
-- the 12 C/C++/Rust toolchain failures present all session are gone. Full
bin/check.sh run end-to-end on this Windows checkout: pytest, eslint,
frontend node:test (57/57), PowerShell/shell parse, and the wheel/sdist
packaging + twine + content checks all report OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 01:37:52 -05:00

605 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Run a short code snippet in an ephemeral working directory.
This is the *second* track of the code-preview feature and deliberately not the
first. `render_preview` (synapse/tools.py) executes nothing server-side: it
validates markup and the chat UI renders it inside an opaque-origin iframe.
That model fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real
toolchain. So those go through here instead, and the result is shown as terminal
output rather than a rendered document.
WHAT THIS IS NOT
----------------
Not a security sandbox. Snippets run as the current user on the host. What this
module actually provides is *containment by limits*, layered:
1. Consent run_snippet is an ACTION tool, so it is withheld entirely
unless `action_tool_policy` is "ask"/"allow" — and on "ask"
every call waits for the user's Approve/Deny in chat.
2. Screening `critique` rejects the obvious-abuse shapes (sockets, process
spawning, absolute paths) before anything is written to disk.
3. Isolation cwd is a fresh temp dir that is deleted afterwards; home and
temp environment variables point at it; the environment is
scrubbed to a small allowlist.
4. Limits wall-clock timeout, RLIMIT_CPU/AS/FSIZE/NPROC on POSIX,
truncated output.
5. Network on Linux, `unshare -rn` when unprivileged user namespaces are
available (probed once, see `_net_isolation`). Nowhere else —
macOS and Windows get no network isolation at all.
Layer 2 is a tripwire, not a boundary: arbitrary Python can evade any regex
trivially. It exists to catch a model that reaches for `requests` by habit, not
an adversary. The load-bearing layers are 1 and 35. Anyone wanting a real
boundary should run this under a container or a VM; that is a deployment
decision this module does not make for them.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache
from pathlib import Path
# Wall clock. Compilation gets its own, larger budget: rustc on a cold cache
# routinely spends longer than any snippet is allowed to *run*, and killing a
# compile at the run timeout would look like the code hung when it never started.
RUN_TIMEOUT = 5.0
COMPILE_TIMEOUT = 25.0
MAX_SOURCE = 100_000 # chars of source accepted
MAX_OUTPUT = 20_000 # chars of stdout/stderr returned, per stream
MAX_STDIN = 10_000
# Applied to the run step only. A compiler legitimately needs more address space
# than a snippet does and forks a linker, so imposing these on the compile step
# breaks the toolchain rather than containing the snippet.
_MEM_BYTES = 512 * 1024 * 1024
_MAX_PROCS = 64
_MAX_FILE_BYTES = 8 * 1024 * 1024
# RLIMIT_NPROC is counted per real UID, not per run. An absolute "512" is
# therefore 512 minus whatever the user already has — enough on a quiet host,
# zero on a busy macOS desktop. Erlang needs relative headroom for BEAM's
# boot process tree; other languages keep the absolute _MAX_PROCS (blocking
# fork is the intent there).
_ERLANG_PROC_HEADROOM = 256
# ---------------------------------------------------------------------------
# Static screening
# ---------------------------------------------------------------------------
def _shared_issues(source: str) -> list[str]:
stripped = source.strip()
if len(stripped) < 8:
return ["source is empty or too short to run."]
issues: list[str] = []
# Only flag URLs inside string literals. A citation in a comment
# (`/* see https://… */`) is not a network reach and bouncing it costs a
# useless retry round — this layer is a tripwire, not a parser.
if re.search(r"""['"][^'"]*https?://[^'"]*['"]""", source):
issues.append(
"source contains an http(s) URL string — the runner has no network "
"access. Inline the data you need."
)
if re.search(r"\b(TODO|FIXME|your code here|implement this)\b", source, re.I):
issues.append("source still contains a placeholder — send the finished code.")
# An absolute path is either reaching outside the ephemeral dir or is a
# machine-specific guess that will not exist. Relative paths are fine: cwd
# is the temp dir and goes away with it.
# Windows: match `C:\…` and `C:\\…` (raw / escaped). Unix: common roots.
if re.search(
r"""['"](?:/(?:etc|home|root|usr|var|proc|sys)/|[A-Za-z]:[/\\])""",
source,
):
issues.append(
"source references an absolute filesystem path. The snippet runs in a "
"throwaway directory — use relative paths, or inline the data."
)
return issues
def _deny(source: str, rules: list[tuple[str, str]]) -> list[str]:
return [msg for pattern, msg in rules if re.search(pattern, source)]
_NO_NET = "the runner has no network access"
_NO_SPAWN = "the runner does not allow spawning other processes"
_PY_RULES = [
(r"\b(?:import|from)\s+(?:socket|ssl|ftplib|smtplib|telnetlib|urllib|http)\b",
f"networking module imported — {_NO_NET}."),
(r"\bimport\s+(?:requests|httpx|aiohttp|urllib3)\b",
f"HTTP client imported — {_NO_NET}."),
(r"\b(?:import|from)\s+(?:subprocess|multiprocessing)\b",
f"subprocess/multiprocessing imported — {_NO_SPAWN}."),
(r"\bos\.(?:system|popen|exec[lv]|fork|spawn|kill)\b",
f"os process call — {_NO_SPAWN}."),
(r"\b(?:import|from)\s+ctypes\b",
"ctypes imported — the runner does not allow native calls."),
]
_C_RULES = [
(r"#\s*include\s*<(?:sys/socket|netinet/|arpa/|netdb)",
f"socket header included — {_NO_NET}."),
(r"\b(?:system|popen|fork|execv?[lpe]*)\s*\(",
f"process call — {_NO_SPAWN}."),
]
_RUST_RULES = [
(r"\bstd::net\b", f"std::net used — {_NO_NET}."),
(r"\bstd::process::(?:Command|abort)\b", f"std::process::Command used — {_NO_SPAWN}."),
]
_ERL_RULES = [
(r"\b(?:gen_tcp|gen_udp|httpc|inets|ssl)\b", f"networking module used — {_NO_NET}."),
(r"\bos:cmd\b", f"os:cmd/1 used — {_NO_SPAWN}."),
]
def _entry_point(pattern: str, message: str):
"""Most of these languages fail with a linker/loader error rather than a
useful one when the entry point is missing, so name it up front."""
def check(source: str) -> list[str]:
return [] if re.search(pattern, source) else [message]
return check
def _critique(rules: list[tuple[str, str]], entry=None):
def check(source: str) -> list[str]:
issues = _shared_issues(source)
if issues and issues[0].startswith("source is empty"):
return issues # nothing else is worth saying about an empty body
issues += _deny(source, rules)
if entry:
issues += entry(source)
return issues
return check
# ---------------------------------------------------------------------------
# Drivers
# ---------------------------------------------------------------------------
def _source_name(default: str):
return lambda _source: default
def _erlang_source_name(source: str) -> str:
"""escript reads the same file two different ways, chosen by extension: a
`.erl` file is compiled as a module (needs -module/-export), anything else
is a plain script (needs only main/1). Models write both, so let the source
pick its own filename instead of forcing one dialect."""
return "main.erl" if re.search(r"^\s*-module\s*\(", source, re.M) else "main.escript"
def _erlang_write_source(source: str) -> str:
"""OTP 28+ escript rejects a shebang-less `.escript` with 'Premature end of
file'. Module-form `.erl` files do not need one. Inject only when missing so
a model that already wrote `#!/usr/bin/env escript` is left alone."""
if _erlang_source_name(source).endswith(".escript"):
stripped = source.lstrip()
if not stripped.startswith("#!"):
return "#!/usr/bin/env escript\n" + source
return source
@lru_cache(maxsize=None)
def _compiled_tool(lang: str, candidates: tuple[str, ...]) -> str | None:
"""Resolve a compiler, verifying the complete Windows toolchain once.
A compiler executable alone is not a usable toolchain on Windows: rustc's
MSVC target also needs Microsoft's linker, and an MSYS2 driver can remain on
PATH after one of its runtime DLLs has broken. Both cases otherwise make the
capability monitor say "ready" and turn every snippet into a compile error.
POSIX keeps the cheap historical which(1) check; the release hosts there
install compiler packages atomically.
"""
found = [tool for name in candidates if (tool := shutil.which(name))]
if sys.platform != "win32":
return found[0] if found else None
for tool in found:
if _probe_compiled_tool(lang, tool):
return tool
return None
def _probe_compiled_tool(lang: str, tool: str) -> bool:
"""Compile a minimal known-good program with the runner's real child env."""
source = {
"c": "int main(void){return 0;}",
"cpp": "int main(){return 0;}",
"rust": "fn main() {}",
}[lang]
try:
with tempfile.TemporaryDirectory(prefix="nexus-toolchain-") as tmp:
workdir = Path(tmp)
entry = RUN_LANGS[lang]
src = workdir / entry["source_name"](source)
src.write_text(source, encoding="utf-8")
exe = str(workdir / "probe.exe")
built = _spawn(
entry["compile"](tool, str(src), exe),
workdir,
_child_env(lang, workdir),
COMPILE_TIMEOUT,
constrain_memory=False,
)
return built.returncode == 0 and Path(exe).is_file()
except (OSError, subprocess.SubprocessError):
return False
# The one place that says which languages can be executed. Each entry owns that
# language's screening, toolchain probe and argv. The tool schema's `lang` enum,
# the capability line in the system prompt and the dispatch below are all derived
# from these keys rather than repeating them.
#
# The frontend keeps a matching registry (RUN_LANGS in
# interface/web/src/preview/run-langs.js) because the two sides need different
# things per language — this side executes, that side labels and displays — and
# neither should depend on the other at runtime. tests/test_tools.py asserts the
# key sets stay equal, so drift fails the check gate instead of silently showing
# a run result with the wrong language on it.
RUN_LANGS: dict[str, dict] = {
"python": {
"summary": "Python script (stdlib only)",
"tool": lambda: sys.executable,
"install": "Python is bundled with NexusOS; this should not happen.",
"source_name": _source_name("main.py"),
# -I is isolated mode: ignores PYTHON* env vars, the user site-packages
# dir and the script's own directory on sys.path.
"compile": None,
"run": lambda tool, src, _exe: [tool, "-I", src],
"critique": _critique(_PY_RULES),
},
"c": {
"summary": "single-file C program (C11, libm linked)",
"tool": lambda: _compiled_tool("c", ("cc", "gcc", "clang")),
"install": "install a C compiler (clang or gcc)",
"source_name": _source_name("main.c"),
"compile": lambda cc, src, exe: [cc, "-std=c11", "-O0", "-Wall", "-o", exe, src, "-lm"],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_C_RULES, _entry_point(
r"\bmain\s*\(", "no main() — a C program needs `int main(void)`.")),
},
"cpp": {
"summary": "single-file C++ program (C++17)",
"tool": lambda: _compiled_tool("cpp", ("c++", "g++", "clang++")),
"install": "install a C++ compiler (clang++ or g++)",
"source_name": _source_name("main.cpp"),
"compile": lambda cc, src, exe: [cc, "-std=c++17", "-O0", "-Wall", "-o", exe, src],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_C_RULES, _entry_point(
r"\bmain\s*\(", "no main() — a C++ program needs `int main()`.")),
},
"rust": {
"summary": "single-file Rust program (2021 edition, std only)",
"tool": lambda: _compiled_tool("rust", ("rustc",)),
"install": "install Rust (https://rustup.rs)",
"source_name": _source_name("main.rs"),
# Debug build: -O roughly triples compile time for snippets that run for
# milliseconds either way.
"compile": lambda cc, src, exe: [cc, "--edition", "2021", "-o", exe, src],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_RUST_RULES, _entry_point(
r"\bfn\s+main\s*\(", "no main() — a Rust program needs `fn main()`.")),
},
"erlang": {
"summary": "escript program with a main/1 entry point",
"tool": lambda: shutil.which("escript"),
"install": "install Erlang/OTP (provides escript)",
"source_name": _erlang_source_name,
"compile": None,
"run": lambda tool, src, _exe: [tool, src],
"critique": _critique(_ERL_RULES, _entry_point(
r"\bmain\s*\(", "no main/1 — escript calls `main(Args)`.")),
},
}
# Spellings a model reaches for that aren't the registry key. Resolved before
# lookup so `c++` and `py` work without doubling the tool schema's enum.
ALIASES = {
"c++": "cpp", "cc": "c", "py": "python", "python3": "python",
"rs": "rust", "erl": "erlang", "escript": "erlang",
}
def resolve_lang(lang: str) -> str:
key = (lang or "").strip().lower()
return ALIASES.get(key, key)
def lang_prose() -> str:
"""'python, c or rust' — the runnable languages as a phrase for prompts."""
names = list(RUN_LANGS)
if len(names) < 2:
return names[0] if names else ""
return f"{', '.join(names[:-1])} or {names[-1]}"
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
# Environment variables the child keeps. Everything else is dropped: the snippet
# has no business seeing API keys, proxy settings or the user's shell config, and
# PYTHON*/LD_* in particular would let ambient config change how it runs.
_ENV_KEEP = ("PATH", "LANG", "LC_ALL", "TERM", "SYSTEMROOT", "COMSPEC")
# rustc is usually a rustup shim, and a shim with HOME rewritten cannot find its
# own toolchain. Passing these through is what makes Rust work at all here; they
# point at read-only toolchain data, not at anything the snippet should write.
_ENV_KEEP_BY_LANG = {"rust": ("RUSTUP_HOME", "CARGO_HOME", "RUSTUP_TOOLCHAIN")}
def _child_env(lang: str, workdir: Path) -> dict:
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
for k in _ENV_KEEP_BY_LANG.get(lang, ()):
if k in os.environ:
env[k] = os.environ[k]
if lang == "rust" and "RUSTUP_HOME" not in env:
# HOME is about to be rewritten, so resolve rustup's default location
# against the real home while we still know it.
default = Path.home() / ".rustup"
if default.is_dir():
env["RUSTUP_HOME"] = str(default)
env.setdefault("CARGO_HOME", str(Path.home() / ".cargo"))
scratch = str(workdir)
env["HOME"] = scratch
env["TMPDIR"] = scratch
# Windows ignores HOME/TMPDIR in its standard path helpers. Without these,
# expanduser() reaches the real profile and GetTempPath() falls back to the
# Windows directory; GCC and rustc then either escape the scratch directory
# or fail because a normal user cannot write there.
env["TEMP"] = scratch
env["TMP"] = scratch
if os.name == "nt":
env["USERPROFILE"] = scratch
env.setdefault("LC_ALL", "C.UTF-8")
return env
def _user_process_count() -> int | None:
"""How many processes this UID already owns. None when we cannot count
(Windows, or psutil missing) — callers must not invent an absolute cap."""
if os.name == "nt" or not hasattr(os, "getuid"):
return None
try:
import psutil # optional; process extra
except ImportError:
return None
uid = os.getuid()
n = 0
for proc in psutil.process_iter(["uids"]):
try:
uids = proc.info.get("uids")
if uids is not None and getattr(uids, "real", None) == uid:
n += 1
except (psutil.Error, TypeError, AttributeError):
continue
return n
def _max_procs_for(lang: str) -> int | None:
"""Soft RLIMIT_NPROC for this run, or None to leave the limit unset.
Erlang: current per-UID count + headroom (RLIMIT_NPROC is UID-scoped).
Everything else: the absolute _MAX_PROCS tripwire against fork bombs.
"""
if lang == "erlang":
current = _user_process_count()
if current is None:
return None
return current + _ERLANG_PROC_HEADROOM
return _MAX_PROCS
def _limits(constrain_memory: bool, max_procs: int | None = None):
"""preexec_fn applying POSIX rlimits, or None where they don't exist.
RLIMIT_CPU is a backstop for the wall-clock timeout: a snippet that ignores
SIGTERM still loses the CPU. The memory and process caps are skipped for
compilation — see _MEM_BYTES.
`max_procs=None` with constrain_memory means "do not set RLIMIT_NPROC"
(used when we cannot compute a relative Erlang ceiling). Passing an int
always sets it.
"""
if sys.platform == "win32":
return None
try:
import resource
except ImportError: # pragma: no cover - POSIX only
return None
cpu = int(COMPILE_TIMEOUT if not constrain_memory else RUN_TIMEOUT) + 1
wanted = [("RLIMIT_CPU", cpu), ("RLIMIT_FSIZE", _MAX_FILE_BYTES), ("RLIMIT_CORE", 0)]
if constrain_memory:
wanted.append(("RLIMIT_AS", _MEM_BYTES))
# Distinguish "caller omitted" (use default) from "explicitly skip"
# by requiring the kw to be passed — see _spawn.
if max_procs is not None:
wanted.append(("RLIMIT_NPROC", max_procs))
def apply(): # runs in the forked child, between fork and exec
# Every limit is set independently and failure is swallowed. Which of
# these exist, and which can be lowered, varies by platform (macOS has
# no usable RLIMIT_AS, RLIMIT_NPROC is absent on some POSIX systems) —
# and an exception raised here does not "skip a limit", it aborts the
# spawn entirely. Partial limits are the right failure mode; no run at
# all is not.
for name, soft in wanted:
which = getattr(resource, name, None)
if which is None:
continue
try:
_, hard = resource.getrlimit(which)
if hard != resource.RLIM_INFINITY:
soft = min(soft, hard)
resource.setrlimit(which, (soft, hard))
except (ValueError, OSError):
continue
return apply
_net_isolation_cache: list | None = None
def _net_isolation() -> list[str]:
"""argv prefix that drops the child into an empty network namespace, or [].
Linux only, and only where unprivileged user namespaces are enabled — which
is a kernel/distro setting we can't change and shouldn't fail over. Probed
once and cached; an empty list means the run simply has host networking, and
callers must not treat this as a guarantee either way.
"""
global _net_isolation_cache
if _net_isolation_cache is not None:
return _net_isolation_cache
_net_isolation_cache = []
if sys.platform.startswith("linux") and shutil.which("unshare"):
try:
probe = subprocess.run(
["unshare", "-rn", "true"],
capture_output=True, timeout=5,
)
if probe.returncode == 0:
_net_isolation_cache = ["unshare", "-rn"]
except (OSError, subprocess.SubprocessError):
pass
return _net_isolation_cache
def _clip(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
if len(text) <= MAX_OUTPUT:
return text
return text[:MAX_OUTPUT] + f"\n... [truncated at {MAX_OUTPUT} characters]"
def _spawn(argv: list[str], workdir: Path, env: dict, timeout: float,
stdin: str = "", constrain_memory: bool = True,
max_procs: int | None = _MAX_PROCS):
"""Spawn a child. `max_procs` defaults to `_MAX_PROCS`; pass `None` to skip
RLIMIT_NPROC entirely (Erlang, when a relative ceiling cannot be computed)."""
return subprocess.run(
argv,
cwd=str(workdir),
env=env,
input=stdin.encode("utf-8"),
capture_output=True,
timeout=timeout,
preexec_fn=_limits(constrain_memory, max_procs=max_procs),
)
def run(lang: str, source: str, stdin: str = "") -> dict:
"""Compile (if needed) and run `source`. Blocking — call from a thread.
Returns {ok, lang, stage, exit_code, stdout, stderr, error}. `ok` is False
only when the snippet could not be run at all (missing toolchain, compile
error, timeout); a program that runs and exits non-zero is a successful run
with a non-zero exit_code, because its stderr is the answer the user wants.
"""
key = resolve_lang(lang)
entry = RUN_LANGS.get(key)
if entry is None:
return {"ok": False, "lang": lang, "stage": "lang",
"error": f"cannot run {lang!r} — use {lang_prose()}."}
tool = entry["tool"]()
if not tool:
return {"ok": False, "lang": key, "stage": "toolchain",
"error": f"no toolchain for {key} on this machine — {entry['install']}. "
"Show the code instead of running it."}
with tempfile.TemporaryDirectory(prefix="nexus-run-") as tmp:
workdir = Path(tmp)
body = _erlang_write_source(source) if key == "erlang" else source
src = workdir / entry["source_name"](source)
src.write_text(body, encoding="utf-8")
exe = str(workdir / ("program.exe" if sys.platform == "win32" else "program"))
env = _child_env(key, workdir)
max_procs = _max_procs_for(key)
if entry["compile"]:
try:
built = _spawn(entry["compile"](tool, str(src), exe), workdir, env,
COMPILE_TIMEOUT, constrain_memory=False)
except subprocess.TimeoutExpired:
return {"ok": False, "lang": key, "stage": "compile",
"error": f"compilation timed out after {COMPILE_TIMEOUT:g}s."}
except OSError as e:
return {"ok": False, "lang": key, "stage": "compile",
"error": f"could not start the compiler: {e}"}
if built.returncode != 0:
return {"ok": False, "lang": key, "stage": "compile",
"exit_code": built.returncode,
"stdout": _clip(built.stdout), "stderr": _clip(built.stderr),
"error": "compilation failed — read stderr, fix the source, "
"and call run_snippet again."}
argv = _net_isolation() + entry["run"](tool, str(src), exe)
try:
done = _spawn(argv, workdir, env, RUN_TIMEOUT, stdin=stdin[:MAX_STDIN],
max_procs=max_procs)
except subprocess.TimeoutExpired as e:
return {"ok": False, "lang": key, "stage": "run",
"stdout": _clip(e.stdout or b""), "stderr": _clip(e.stderr or b""),
"error": f"the program did not finish within {RUN_TIMEOUT:g}s — "
"it is probably looping. Bound the work and try again."}
except OSError as e:
return {"ok": False, "lang": key, "stage": "run",
"error": f"could not start the program: {e}"}
stdout = _clip(done.stdout)
stderr = _clip(done.stderr)
# BEAM failing to fork under RLIMIT_NPROC looks like a snippet bug if we
# report ok=True. Call it out as a runner limit so the model does not keep
# rewriting a correct program.
if (
key == "erlang"
and done.returncode != 0
and "Resource temporarily unavailable" in stderr
):
return {
"ok": False,
"lang": key,
"stage": "run",
"exit_code": done.returncode,
"stdout": stdout,
"stderr": stderr,
"error": (
"Erlang could not start under the process limit (host already has "
"many processes for this user). Free some processes and retry, or "
"show the code instead of running it."
),
}
return {
"ok": True,
"lang": key,
"stage": "run",
"exit_code": done.returncode,
"stdout": stdout,
"stderr": stderr,
}
def critique(lang: str, source: str) -> list[str]:
"""Static screening for one snippet. See the module docstring on what this
is worth: a tripwire against habitual network/process reaches, not a
boundary."""
key = resolve_lang(lang)
entry = RUN_LANGS.get(key)
if entry is None:
return [f"cannot run {lang!r} — use {lang_prose()}."]
if len(source) > MAX_SOURCE:
return [f"source is over {MAX_SOURCE} characters — send a single focused snippet."]
return entry["critique"](source)