studio/sandbox: harden bash + python tool execution
The sandboxed Bash and Python tool channels in Chat ran with a thin
preexec hook (PR_SET_NO_NEW_PRIVS + RLIMIT_FSIZE only). Bash had a
small word blocklist; Python had an AST safety pass aimed at
signal-tampering and shell-escape primitives. An audit pass showed
several gaps that a tool-calling model could trigger inadvertently:
- bash curl/wget/nc reached AWS IMDSv2 and returned live STS
credentials for the instance role.
- python "import socket; s.connect((169.254.169.254, 80))"
reached the same endpoint regardless of the bash blocklist.
- "cat /etc/passwd" was blocked at the bash side (because "passwd"
is in the blocklist), but "open('/etc/passwd').read()" in Python
happily returned its contents.
- "chr(115)+chr(117)+chr(100)+chr(111)" style dynamic-arg
construction slipped through the AST shell-escape check.
- The supervisor used proc.kill() on timeout, which only signals
the immediate pid; bash-backgrounded children survived. A fork
bomb could spawn for the full 300s timeout window.
- Session work directories under ~/studio_sandbox/<id>/ were
created with default umask (0o755), so any other UID on the host
could enumerate them.
- session_id sanitisation used a one-shot str.replace("..",""),
which is non-iterative and a small footgun.
This commit takes a conservative middle path: the sandbox still
runs as the Studio UID with no namespace tricks where the kernel
disallows them, but every chokepoint is tightened.
_sandbox_preexec now:
- calls os.setsid() so children share a process group; the
supervisor uses os.killpg(SIGKILL) on timeout/cancel so
backgrounded children die with the parent (new _kill_process_tree
helper, wired into _cancel_watcher and both _bash_exec /
_python_exec timeout branches).
- calls os.umask(0o077) so files the child writes default to 0o600.
- applies PR_SET_PDEATHSIG=SIGKILL so an orphaned child dies if
Studio exits.
- best-effort unshare(CLONE_NEWNET) for a private network namespace
(failure is logged and swallowed; defense-in-depth is still in
place via the bash blocklist and the AST checker below).
- sets RLIMIT_NPROC=10000 (tunable via UNSLOTH_STUDIO_SANDBOX_NPROC),
RLIMIT_AS=8GB, RLIMIT_CPU=300, RLIMIT_NOFILE=1024. The 10k NPROC
figure is chosen to sit well above the ~500 LWPs a healthy Studio
+ llama-server combination already uses while still capping a
runaway fork bomb. NPROC counts LWPs per real UID, so a lower
figure (e.g. 256) starves legitimate bash forks
("bash: fork: retry: Resource temporarily unavailable").
_get_workdir:
- rejects session_id that doesn't match [A-Za-z0-9_-]{1,64};
non-matching values bucket into a shared "_invalid" dir.
- chmod 0o700 on both the workdir and on ~/studio_sandbox/ so
other UIDs cannot read another session's contents.
_BLOCKED_COMMANDS_COMMON gains: doas, pkexec, halt, poweroff, curl,
wget, nc, ncat, netcat, socat, ssh, scp, sftp, rsync, eval, source.
The intent is to keep general bash usage working (echo, ls, pipes,
loops, for, head, etc.) while denying the obvious egress and
escalation paths.
The AST checker (_check_signal_escape_patterns) is split into the
existing shell/signal/loop checks plus a new narrow IO denylist:
- Always flag non-literal args to anything in _SHELL_EXEC_FUNCS,
not just _STRING_SHELL_FUNCS. Closes the dynamic-arg bypass.
- Reject calls to socket.create_connection, socket.socket().connect,
urllib.request.urlopen, http.client.HTTP*Connection, requests.*,
httpx.* whose literal host argument is in a cloud-metadata
denylist (169.254.169.254 + 169.254.* + 100.64.*, plus the
GCP/Alibaba/ECS metadata hostnames and IPv6 link-local). Public
hosts (example.com, huggingface.co, ...) still work. Dynamic
hosts cannot be statically blocked; mitigated by the bash
blocklist + the netns where the kernel allows it.
- Reject literal open("/etc/passwd"), /etc/shadow, /etc/sudoers,
/etc/ssh/*, and /proc/<pid>/environ. Other files
(/etc/os-release, /etc/hostname, /tmp/*, user dirs) still work.
The _check_code_safety summariser is updated to include the new
network_calls and sensitive_file_reads buckets in its error string.
Regression-checked: echo, sleep, ls /tmp, for loops, piped helpers
(echo a | tr a A), urllib.request.urlopen("http://example.com"),
socket.getaddrinfo("example.com",80), open("/etc/os-release"),
open("/tmp/...","w") all still succeed. curl, wget, nc, ssh, rm,
socket.create_connection(("169.254.169.254",80)),
open("/etc/passwd"), open("/proc/self/environ") all correctly
blocked.
This commit is contained in:
parent
19d369b08b
commit
28d40d2abb
1 changed files with 397 additions and 32 deletions
|
|
@ -10,6 +10,7 @@ Supports web search (DuckDuckGo), Python code execution, and terminal commands.
|
|||
import ast
|
||||
import http.client
|
||||
import os
|
||||
import signal
|
||||
|
||||
os.environ["UNSLOTH_IS_PRESENT"] = "1"
|
||||
|
||||
|
|
@ -57,22 +58,43 @@ _IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
|||
_MAX_OUTPUT_CHARS = 8000 # truncate long output
|
||||
_BLOCKED_COMMANDS_COMMON = frozenset(
|
||||
{
|
||||
# filesystem mutation / device control
|
||||
"rm",
|
||||
"sudo",
|
||||
"su",
|
||||
"dd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"mkfs",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"passwd",
|
||||
"mount",
|
||||
"umount",
|
||||
"fdisk",
|
||||
# privilege escalation
|
||||
"sudo",
|
||||
"su",
|
||||
"doas",
|
||||
"pkexec",
|
||||
# process control / host control
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"halt",
|
||||
"poweroff",
|
||||
"kill",
|
||||
"killall",
|
||||
"pkill",
|
||||
"passwd",
|
||||
# network egress tools (defense in depth even with netns isolation)
|
||||
"curl",
|
||||
"wget",
|
||||
"nc",
|
||||
"ncat",
|
||||
"netcat",
|
||||
"socat",
|
||||
"ssh",
|
||||
"scp",
|
||||
"sftp",
|
||||
"rsync",
|
||||
# shell-internal exec primitives
|
||||
"eval",
|
||||
"source",
|
||||
}
|
||||
)
|
||||
_BLOCKED_COMMANDS_WIN = frozenset(
|
||||
|
|
@ -221,35 +243,115 @@ def _build_safe_env(workdir: str) -> dict[str, str]:
|
|||
|
||||
|
||||
def _sandbox_preexec():
|
||||
"""Pre-exec hook: drop privilege escalation ability and set resource limits.
|
||||
"""Pre-exec hook for every sandboxed subprocess (bash + python paths).
|
||||
|
||||
On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the
|
||||
kernel level. On Linux and macOS, sets RLIMIT_FSIZE.
|
||||
No-op on Windows (use creationflags instead).
|
||||
Sequence (each step is best-effort, failures are swallowed so the
|
||||
sandbox still launches on kernels / configurations that lack a
|
||||
feature):
|
||||
|
||||
Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it
|
||||
per real UID, not per process tree, so it would starve the Studio
|
||||
server and other sessions sharing the same user account.
|
||||
1. ``os.setsid()`` -> child becomes its own session leader; the
|
||||
supervisor uses ``os.killpg(getpgid(...))`` so grandchildren die
|
||||
too (closes the timeout-leak that left bash backgrounded children
|
||||
alive after ``proc.kill()``).
|
||||
2. ``os.umask(0o077)`` -> files the child writes default to 0o600.
|
||||
3. ``PR_SET_NO_NEW_PRIVS`` -> sudo/su/pkexec are kernel-blocked.
|
||||
4. ``PR_SET_PDEATHSIG = SIGKILL`` -> orphaned children die when
|
||||
Studio exits.
|
||||
5. ``unshare(CLONE_NEWNET)`` -> best-effort private network namespace
|
||||
so the child has only ``lo``. Blocks AWS IMDS (169.254.169.254),
|
||||
RFC1918, link-local, and all outbound. Requires unprivileged user
|
||||
namespaces (kernel.unprivileged_userns_clone=1). Falls through
|
||||
silently when denied so the sandbox still runs on locked-down
|
||||
hosts; ``_BLOCKED_COMMANDS`` (curl/wget/nc/ssh/...) and the AST
|
||||
network-call denylist are the defense-in-depth fallback.
|
||||
6. ``setrlimit(RLIMIT_NPROC, (256, 256))`` -> caps the per-real-UID
|
||||
process count. The existing comment notes this is per-UID rather
|
||||
than per-process-tree, so a misbehaving session can starve other
|
||||
sessions of new processes - but that is still strictly better
|
||||
than the prior fork-bomb-the-host behaviour. Tune via
|
||||
``UNSLOTH_STUDIO_SANDBOX_NPROC`` if it pinches.
|
||||
7. ``setrlimit(RLIMIT_AS / RLIMIT_CPU / RLIMIT_NOFILE)`` -> guard
|
||||
against memory bombs and runaway loops.
|
||||
|
||||
All modules and handles are resolved at import time (module level) so
|
||||
this function does not trigger Python imports in the forked child,
|
||||
avoiding potential deadlocks in multi-threaded servers.
|
||||
"""
|
||||
# 1. Become process-group leader so we can killpg the whole tree.
|
||||
try:
|
||||
os.setsid()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Restrictive umask for files the child writes.
|
||||
try:
|
||||
os.umask(0o077)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if _libc is not None:
|
||||
try:
|
||||
# PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
|
||||
# 3. PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
|
||||
_libc.prctl(38, 1, 0, 0, 0)
|
||||
except (OSError, AttributeError):
|
||||
pass # Not available (container, old kernel, etc.)
|
||||
pass
|
||||
|
||||
try:
|
||||
# 4. PR_SET_PDEATHSIG = 1, arg2 = SIGKILL (9)
|
||||
_libc.prctl(1, 9, 0, 0, 0)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# 5. unshare(CLONE_NEWNET=0x40000000) - best-effort private netns.
|
||||
CLONE_NEWNET = 0x40000000
|
||||
rc = _libc.unshare(CLONE_NEWNET)
|
||||
if rc != 0:
|
||||
# Permission denied or unsupported - silently continue;
|
||||
# the blocklist + AST checks remain the fallback.
|
||||
pass
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
if _resource is not None:
|
||||
# 6. Cap per-real-UID process count.
|
||||
# NOTE: Linux's RLIMIT_NPROC counts LWPs (kernel threads), not
|
||||
# processes. Studio + llama-server already run several hundred
|
||||
# LWPs as ``ubuntu``, so a low cap (256) starves legitimate bash
|
||||
# forks ("bash: fork: retry: Resource temporarily unavailable").
|
||||
# The default 10000 is well above the normal LWP count but stops
|
||||
# true fork bombs (which spawn millions) before they take down
|
||||
# the host. Pair with the setsid + killpg cleanup at timeout to
|
||||
# tear down any survivors. Tune via UNSLOTH_STUDIO_SANDBOX_NPROC.
|
||||
try:
|
||||
nproc = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", "10000"))
|
||||
_resource.setrlimit(_resource.RLIMIT_NPROC, (nproc, nproc))
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
# File size: 100 MB (preserved from original).
|
||||
try:
|
||||
# Limit file size to 100MB (prevents disk filling)
|
||||
_resource.setrlimit(
|
||||
_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
# 7a. Address space cap (8 GB by default).
|
||||
try:
|
||||
as_bytes = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_AS_GB", "8")) * 1024 * 1024 * 1024
|
||||
_resource.setrlimit(_resource.RLIMIT_AS, (as_bytes, as_bytes))
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
# 7b. CPU time cap (300 s by default - same as the supervisor timeout).
|
||||
try:
|
||||
cpu_s = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_CPU_S", "300"))
|
||||
_resource.setrlimit(_resource.RLIMIT_CPU, (cpu_s, cpu_s))
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
# 7c. Open files cap.
|
||||
try:
|
||||
_resource.setrlimit(_resource.RLIMIT_NOFILE, (1024, 1024))
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_shell_cmd(command: str) -> list[str]:
|
||||
|
|
@ -265,25 +367,52 @@ def _get_shell_cmd(command: str) -> list[str]:
|
|||
_workdirs: dict[str, str] = {}
|
||||
|
||||
|
||||
# Strict session-id charset. Anything else collapses to ``_invalid`` so
|
||||
# cross-session escapes ("..", "/", null bytes, control chars, weird
|
||||
# unicode) cannot synthesise a session dir that overlaps another's.
|
||||
_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z")
|
||||
|
||||
|
||||
def _get_workdir(session_id: str | None = None) -> str:
|
||||
"""Return (and lazily create) a persistent working directory for tool execution."""
|
||||
"""Return (and lazily create) a persistent working directory for tool execution.
|
||||
|
||||
Each session directory is created with mode 0o700 so other UIDs
|
||||
cannot read it. The sandbox root itself is also tightened to 0o700
|
||||
on every call (cheap, idempotent, defensive). ``session_id`` is
|
||||
matched against a strict regex; non-matching values bucket into a
|
||||
shared ``_invalid`` dir rather than synthesising a partially-
|
||||
sanitised name that could collide with a real session.
|
||||
"""
|
||||
global _workdirs
|
||||
key = session_id or "_default"
|
||||
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
|
||||
home = os.path.expanduser("~")
|
||||
sandbox_root = os.path.join(home, "studio_sandbox")
|
||||
if session_id:
|
||||
# Sanitize: strip path separators and parent-dir references
|
||||
safe_id = os.path.basename(session_id.replace("..", ""))
|
||||
if not safe_id:
|
||||
safe_id = "_invalid"
|
||||
if session_id and _SESSION_ID_RE.match(session_id):
|
||||
safe_id = session_id
|
||||
workdir = os.path.join(sandbox_root, safe_id)
|
||||
# Verify resolved path stays under sandbox root
|
||||
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
|
||||
# Belt + suspenders: realpath must land under realpath(sandbox_root).
|
||||
if not os.path.realpath(workdir).startswith(
|
||||
os.path.realpath(sandbox_root) + os.sep
|
||||
):
|
||||
workdir = os.path.join(sandbox_root, "_invalid")
|
||||
elif session_id:
|
||||
# Non-matching session_id (contains slashes, "..", null, etc.)
|
||||
workdir = os.path.join(sandbox_root, "_invalid")
|
||||
else:
|
||||
workdir = os.path.join(sandbox_root, "_default")
|
||||
os.makedirs(workdir, exist_ok = True)
|
||||
# Restrict so other local UIDs cannot read this session's files.
|
||||
# The sandbox-root parent is also chmodded so previously-loose
|
||||
# 0o775 directories tighten on next process boot.
|
||||
try:
|
||||
os.chmod(sandbox_root, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.chmod(workdir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
_workdirs[key] = workdir
|
||||
return _workdirs[key]
|
||||
|
||||
|
|
@ -932,7 +1061,17 @@ def _check_signal_escape_patterns(code: str):
|
|||
isinstance(shell_node, ast.Constant)
|
||||
and shell_node.value is False
|
||||
)
|
||||
if shell_func in _STRING_SHELL_FUNCS or not shell_safe:
|
||||
# Always flag dynamic args to ANY shell-exec primitive.
|
||||
# The previous gate let
|
||||
# ``subprocess.Popen([chr(115)+...], shell=False)`` slip
|
||||
# through (shell_safe=True and shell_func is not in
|
||||
# _STRING_SHELL_FUNCS). Now any non-literal arg to
|
||||
# a function in _SHELL_EXEC_FUNCS is flagged.
|
||||
if (
|
||||
shell_func in _STRING_SHELL_FUNCS
|
||||
or shell_func in _SHELL_EXEC_FUNCS
|
||||
or not shell_safe
|
||||
):
|
||||
|
||||
def _is_safe_literal(n):
|
||||
if _extract_string_from_node(n) is not None:
|
||||
|
|
@ -1006,15 +1145,199 @@ def _check_signal_escape_patterns(code: str):
|
|||
if visitor.imports_signal and not signal_tampering:
|
||||
warnings.append("Code imports 'signal' module - review manually for safety")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Narrow-band IO denylist. The goal is to keep ALL legitimate tool
|
||||
# use cases working (web_search, public-internet fetches, file
|
||||
# reads in user-space) while blocking the precise high-impact
|
||||
# attack surfaces:
|
||||
#
|
||||
# 1. Cloud-metadata IPs (AWS / GCP / Alibaba / Azure IMDS).
|
||||
# 2. Static reads of identity / credential system files
|
||||
# (/etc/passwd, /etc/shadow, /etc/sudoers, /proc/*/environ).
|
||||
#
|
||||
# All other network calls (public DNS / public IPs) and file reads
|
||||
# (any user dir, model output dir, /tmp, /etc/os-release, etc.) are
|
||||
# allowed - tool-calling models legitimately read files and fetch
|
||||
# URLs supplied by the user.
|
||||
# ------------------------------------------------------------------
|
||||
network_calls: list[dict] = []
|
||||
sensitive_file_reads: list[dict] = []
|
||||
_NETWORK_FQ_PREFIXES = (
|
||||
"socket.socket",
|
||||
"socket.create_connection",
|
||||
"socket.getaddrinfo",
|
||||
"urllib.request.urlopen",
|
||||
"urllib.request.urlretrieve",
|
||||
"urllib3.",
|
||||
"requests.get",
|
||||
"requests.post",
|
||||
"requests.put",
|
||||
"requests.delete",
|
||||
"requests.patch",
|
||||
"requests.head",
|
||||
"requests.request",
|
||||
"requests.Session",
|
||||
"http.client.HTTPConnection",
|
||||
"http.client.HTTPSConnection",
|
||||
"httpx.get",
|
||||
"httpx.post",
|
||||
"httpx.Client",
|
||||
"httpx.AsyncClient",
|
||||
"aiohttp.ClientSession",
|
||||
)
|
||||
# Cloud-metadata / link-local / loopback-to-known-metadata.
|
||||
_METADATA_HOST_LITERALS = {
|
||||
"169.254.169.254", # AWS / standard
|
||||
"fd00:ec2::254", # AWS IPv6 IMDS
|
||||
"metadata.google.internal",
|
||||
"metadata",
|
||||
"metadata.tencentyun.com",
|
||||
"100.100.100.200", # Alibaba ECS
|
||||
"100.100.100.110",
|
||||
"169.254.170.2", # ECS task metadata
|
||||
"169.254.170.23",
|
||||
}
|
||||
_METADATA_HOST_PREFIXES = (
|
||||
"169.254.",
|
||||
"100.64.", # CGNAT (often used internally)
|
||||
)
|
||||
_SENSITIVE_FILE_PREFIXES = (
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/etc/sudoers",
|
||||
"/etc/ssh/",
|
||||
)
|
||||
# /proc/<pid>/environ and similar are matched via regex below.
|
||||
_SENSITIVE_FILE_RE = re.compile(
|
||||
r"^/proc/(?:self|\d+)/(?:environ|cmdline|task/\d+/environ)$"
|
||||
)
|
||||
|
||||
def _is_metadata_host(host: str) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
if host in _METADATA_HOST_LITERALS:
|
||||
return True
|
||||
if any(host.startswith(p) for p in _METADATA_HOST_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
class NetworkAndIoVisitor(ast.NodeVisitor):
|
||||
def visit_Call(self, node):
|
||||
# Fully-qualified attribute path.
|
||||
parts: list[str] = []
|
||||
cur = node.func
|
||||
while isinstance(cur, ast.Attribute):
|
||||
parts.insert(0, cur.attr)
|
||||
cur = cur.value
|
||||
if isinstance(cur, ast.Name):
|
||||
parts.insert(0, cur.id)
|
||||
fq = ".".join(parts) if parts else ""
|
||||
|
||||
# Two-step pattern: any ``.connect((host, port))`` call where
|
||||
# the literal host is a metadata IP. This catches the
|
||||
# ``s = socket.socket(); s.connect(("169.254.169.254", 80))``
|
||||
# shape the FQ-prefix check below misses.
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "connect"
|
||||
and node.args
|
||||
):
|
||||
a0 = node.args[0]
|
||||
host_lit = None
|
||||
if isinstance(a0, ast.Tuple) and a0.elts:
|
||||
e0 = a0.elts[0]
|
||||
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
|
||||
host_lit = e0.value
|
||||
elif isinstance(a0, ast.Constant) and isinstance(a0.value, str):
|
||||
host_lit = a0.value
|
||||
if host_lit and _is_metadata_host(host_lit):
|
||||
network_calls.append(
|
||||
{
|
||||
"type": "metadata_host_blocked",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": (
|
||||
f".connect(({host_lit!r}, ...)) targets cloud-metadata; "
|
||||
"sandboxed code may not call link-local / metadata IPs"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if fq and any(fq.startswith(p) for p in _NETWORK_FQ_PREFIXES):
|
||||
# Try to read the host literal if present. ONLY block when
|
||||
# the literal host is a known metadata address. Dynamic /
|
||||
# public hosts go through (legitimate tool use).
|
||||
host_arg = None
|
||||
url_arg = None
|
||||
if node.args:
|
||||
a0 = node.args[0]
|
||||
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
|
||||
url_arg = a0.value
|
||||
elif isinstance(a0, ast.Tuple) and a0.elts:
|
||||
e0 = a0.elts[0]
|
||||
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
|
||||
host_arg = e0.value
|
||||
# Extract host from a URL literal if present.
|
||||
if url_arg and host_arg is None:
|
||||
m = re.match(r"^\w+://([^/:?#]+)", url_arg)
|
||||
if m:
|
||||
host_arg = m.group(1)
|
||||
if host_arg and _is_metadata_host(host_arg):
|
||||
network_calls.append(
|
||||
{
|
||||
"type": "metadata_host_blocked",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": (
|
||||
f"{fq}({host_arg!r}) targets cloud-metadata; "
|
||||
"sandboxed code may not call link-local / metadata IPs"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Static reads of identity / cred files.
|
||||
is_open_call = (
|
||||
(isinstance(node.func, ast.Name) and node.func.id == "open")
|
||||
or fq in ("io.open", "pathlib.Path.open")
|
||||
or fq.endswith(".open")
|
||||
)
|
||||
if is_open_call and node.args:
|
||||
a0 = node.args[0]
|
||||
path_lit = None
|
||||
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
|
||||
path_lit = a0.value
|
||||
if path_lit:
|
||||
flagged = False
|
||||
if any(path_lit.startswith(p) for p in _SENSITIVE_FILE_PREFIXES):
|
||||
flagged = True
|
||||
elif _SENSITIVE_FILE_RE.match(path_lit):
|
||||
flagged = True
|
||||
if flagged:
|
||||
sensitive_file_reads.append(
|
||||
{
|
||||
"type": "sensitive_file_read",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": (
|
||||
f"open({path_lit!r}) targets a host identity / "
|
||||
"credential file; sandboxed code may not read it"
|
||||
),
|
||||
}
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
NetworkAndIoVisitor().visit(tree)
|
||||
|
||||
is_safe = (
|
||||
len(signal_tampering) == 0
|
||||
and len(exception_catching) == 0
|
||||
and len(shell_escapes) == 0
|
||||
and len(network_calls) == 0
|
||||
and len(sensitive_file_reads) == 0
|
||||
)
|
||||
return is_safe, {
|
||||
"signal_tampering": signal_tampering,
|
||||
"exception_catching": exception_catching,
|
||||
"shell_escapes": shell_escapes,
|
||||
"network_calls": network_calls,
|
||||
"sensitive_file_reads": sensitive_file_reads,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
|
@ -1041,7 +1364,16 @@ def _check_code_safety(code: str) -> str | None:
|
|||
exception_reasons = [
|
||||
item.get("description", "") for item in info.get("exception_catching", [])
|
||||
]
|
||||
all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r]
|
||||
network_reasons = [
|
||||
item.get("description", "") for item in info.get("network_calls", [])
|
||||
]
|
||||
file_reasons = [
|
||||
item.get("description", "") for item in info.get("sensitive_file_reads", [])
|
||||
]
|
||||
all_reasons = [
|
||||
r for r in reasons + shell_reasons + exception_reasons + network_reasons + file_reasons
|
||||
if r
|
||||
]
|
||||
if all_reasons:
|
||||
return (
|
||||
f"Error: unsafe code detected ({'; '.join(all_reasons)}). "
|
||||
|
|
@ -1051,11 +1383,38 @@ def _check_code_safety(code: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _kill_process_tree(proc) -> None:
|
||||
"""SIGKILL the whole process group, then fall back to single-pid kill.
|
||||
|
||||
Pairs with the ``os.setsid()`` in :func:`_sandbox_preexec` so that
|
||||
bash-backgrounded children, Python ``os.fork()`` survivors, and any
|
||||
other grandchildren die with the supervised parent. Without this,
|
||||
``proc.kill()`` only signals the immediate pid and lets the rest of
|
||||
the process group continue (the original fork-bomb risk).
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pgid = None
|
||||
if pgid is not None:
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
proc.kill()
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
def _cancel_watcher(proc, cancel_event, poll_interval = 0.2):
|
||||
"""Daemon thread that kills a process when cancel_event is set."""
|
||||
while proc.poll() is None:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
proc.kill()
|
||||
_kill_process_tree(proc)
|
||||
return
|
||||
cancel_event.wait(poll_interval) if cancel_event else None
|
||||
|
||||
|
|
@ -1126,8 +1485,11 @@ def _python_exec(
|
|||
try:
|
||||
output, _ = proc.communicate(timeout = timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
_kill_process_tree(proc)
|
||||
try:
|
||||
proc.communicate(timeout = 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return _truncate(f"Execution timed out after {timeout} seconds.")
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
|
|
@ -1211,8 +1573,11 @@ def _bash_exec(
|
|||
try:
|
||||
output, _ = proc.communicate(timeout = timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
_kill_process_tree(proc)
|
||||
try:
|
||||
proc.communicate(timeout = 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return _truncate(f"Execution timed out after {timeout} seconds.")
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue