Reproduction steps:
- Build the handler namespace and install the sandbox a module opted into:
ns = {}
module_sandbox.install_restrictions(ns, {"filesystem_writes":["/tmp/acme/"]}, "acme/pay")
(The Modules tab now shows filesystem_writes = ["/tmp/acme/"].)
- Confirm the gate works on the paths it wraps:
ns["open"]("/tmp/victim","w") -> SandboxViolation
ns["os"].remove("/tmp/victim") -> SandboxViolation
- Write OUTSIDE the allowlist through paths the gate never wrapped:
fd = ns["os"].open("/tmp/victim", os.O_WRONLY|os.O_TRUNC); ns["os"].write(fd, b"x")
import shutil; shutil.copy("/tmp/victim","/tmp/exfil"); shutil.rmtree("/tmp/dir")
ns["os"].symlink("/tmp/victim","/tmp/acme/innocent.txt") # link INSIDE allowlist
ns["open"]("/tmp/acme/innocent.txt","w").write("x") # writes through to /tmp/victim
Expected:
A module scoped to /tmp/acme/** cannot write, copy, delete or truncate anything
outside /tmp/acme, by any method; the Modules-tab allowlist is a real boundary.
Actual:
os.open, shutil.copy/move/rmtree, os.symlink, os.truncate, os.link, os.removedirs
and os.renames all execute unchecked. The gate wraps only builtin open() and
os.{remove,unlink,rename,replace,rmdir,mkdir,makedirs}. The symlink case is a
clean escape: the link lives inside the allowlist, so the sandbox-approved open()
passes the path check and writes through to the outside target — because
_path_matches uses os.path.abspath, which does not resolve symlinks. The
_install_filesystem_gate docstring also claims to wrap "shutil.*" (shutil appears
once in the file, in that sentence, and is never wrapped). This module's "What it
stops" list claims a write to ~/.ssh/config raises; it does not.
Suggested fix:
In _install_filesystem_gate: (a) match on the realpath so a write through an
allowed-path symlink is checked against its true target; (b) wrap the missing os
mutators; (c) inject a shutil proxy.
def _resolved(path):
p = os.fspath(path) if hasattr(os, "fspath") else str(path)
parent = os.path.realpath(os.path.dirname(os.path.abspath(p)))
return os.path.join(parent, os.path.basename(p))
# use _path_matches(_resolved(path), allow_write_globs) everywhere
for name in ("remove","unlink","rmdir","mkdir","makedirs",
"removedirs","truncate","open"):
_wrap_path_mutator(name) # checks arg 0 (the path)
def _wrap_dst_mutator(fn_name, dst_index): # created path is arg dst_index
original = getattr(_real_os, fn_name, None)
if not callable(original): return
def _w(*a, **k):
if len(a) > dst_index and not _path_matches(_resolved(a[dst_index]), allow_write_globs):
raise SandboxViolation(f"module {slug!r} tried os.{fn_name} outside filesystem_writes")
return original(*a, **k)
setattr(_os_proxy, fn_name, _w)
_wrap_dst_mutator("symlink", 1); _wrap_dst_mutator("link", 1)
# rename/replace/renames already check arg 0 (src); add a second check on arg 1 (dst).
import shutil as _real_sh, types as _types
_sh = _types.ModuleType("shutil")
for a in dir(_real_sh):
if not a.startswith("__"): setattr(_sh, a, getattr(_real_sh, a))
def _sh_guard(fn_name, dst_index):
orig = getattr(_real_sh, fn_name, None)
if not callable(orig): return
def _w(*a, **k):
if len(a) > dst_index and not _path_matches(_resolved(a[dst_index]), allow_write_globs):
raise SandboxViolation(f"module {slug!r} tried shutil.{fn_name} outside filesystem_writes")
return orig(*a, **k)
setattr(_sh, fn_name, _w)
for fn, i in (("copy",1),("copy2",1),("copyfile",1),("copytree",1),("move",1)):
_sh_guard(fn, i)
_sh.rmtree = lambda *a, **k: (_ for _ in ()).throw(
SandboxViolation(f"module {slug!r} tried shutil.rmtree outside filesystem_writes"))
ns["shutil"] = _sh
This closes the ordinary-method write escapes; the language-layer bypasses the
module discloses (ctypes, raw fd) remain and need OS-level isolation. Also
correct the docstring to list exactly which primitives are wrapped.
Station version (railcall version): station-v0.99
Module slug + version: N/A (platform — workbench/module_sandbox.py)