feat(nono): add isolated evidence harness
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"""Small, argv-only adapter around the installed Nono executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Iterable, Iterator, Mapping, Sequence
|
||||
|
||||
|
||||
_VERSION_RE = re.compile(r"\bnono\s+v?(\d+\.\d+\.\d+)\b", re.IGNORECASE)
|
||||
_REQUIRED_PATH_ENV = (
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"TMPDIR",
|
||||
)
|
||||
_ALLOWED_ENVIRONMENT = frozenset(
|
||||
{
|
||||
*_REQUIRED_PATH_ENV,
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PYTHONUNBUFFERED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NonoError(RuntimeError):
|
||||
"""Base error for adapter and host-gate failures."""
|
||||
|
||||
|
||||
class HostRequirementError(NonoError):
|
||||
"""The mandatory host gate cannot be satisfied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
argv: tuple[str, ...]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.returncode == 0 and not self.timed_out
|
||||
|
||||
|
||||
class NonoAdapter:
|
||||
"""Execute only known Nono subcommands with an injected absolute executable.
|
||||
|
||||
The adapter never writes to a profile directory. Profile arguments are always
|
||||
absolute JSON paths, which also makes it impossible for a test to accidentally
|
||||
select an active profile by name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executable: str | os.PathLike[str],
|
||||
cwd: str | os.PathLike[str],
|
||||
timeout: float = 20.0,
|
||||
max_output: int = 64 * 1024,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
executable_path = Path(executable)
|
||||
cwd_path = Path(cwd)
|
||||
if not executable_path.is_absolute():
|
||||
raise ValueError("nono executable must be an absolute path")
|
||||
if not cwd_path.is_absolute():
|
||||
raise ValueError("adapter cwd must be an absolute path")
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
if max_output < 1:
|
||||
raise ValueError("max_output must be positive")
|
||||
self.executable = str(executable_path)
|
||||
self.cwd = str(cwd_path)
|
||||
self.timeout = timeout
|
||||
if environment is None:
|
||||
raise ValueError("an explicit isolated environment is required")
|
||||
self.max_output = max_output
|
||||
self.environment = _validated_environment(environment)
|
||||
|
||||
def version(self) -> CommandResult:
|
||||
return self._execute((self.executable, "--version"))
|
||||
|
||||
def require_host_0_74(self) -> str:
|
||||
"""Require Linux and an installed Nono in the exact 0.74 release line."""
|
||||
if platform.system() != "Linux":
|
||||
raise HostRequirementError("mandatory Nono host checks require Linux")
|
||||
if not os.path.isfile(self.executable) or not os.access(self.executable, os.X_OK):
|
||||
raise HostRequirementError(f"Nono executable is unavailable: {self.executable}")
|
||||
try:
|
||||
result = self.version()
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise HostRequirementError(f"could not execute Nono: {error}") from error
|
||||
if not result.ok:
|
||||
raise HostRequirementError(
|
||||
f"Nono version check failed (exit {result.returncode}): {result.stderr}"
|
||||
)
|
||||
match = _VERSION_RE.search(result.stdout + "\n" + result.stderr)
|
||||
if match is None or not match.group(1).startswith("0.74."):
|
||||
observed = (result.stdout + result.stderr).strip() or "unknown"
|
||||
raise HostRequirementError(
|
||||
f"mandatory host checks require Nono 0.74.x; observed {observed!r}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
def validate(self, profile: str | os.PathLike[str], *, strict: bool = True) -> CommandResult:
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
command = [self.executable, "profile", "validate"]
|
||||
if strict:
|
||||
command.append("--strict")
|
||||
command.append(str(profile_path))
|
||||
return self._execute(command)
|
||||
|
||||
def show(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
return self._execute(
|
||||
[self.executable, "profile", "show", str(profile_path), "--json"]
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
command: Sequence[str | os.PathLike[str]],
|
||||
*,
|
||||
allow_cwd: bool = False,
|
||||
) -> CommandResult:
|
||||
if isinstance(command, (str, bytes)):
|
||||
raise TypeError("sandbox command must be an argv sequence, not a shell string")
|
||||
command_args = [os.fspath(argument) for argument in command]
|
||||
if not command_args or any(not isinstance(argument, str) for argument in command_args):
|
||||
raise ValueError("sandbox command must contain at least one argv string")
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
nono_args = [self.executable, "run", "--profile", str(profile_path)]
|
||||
if allow_cwd:
|
||||
nono_args.append("--allow-cwd")
|
||||
return self._execute([*nono_args, "--", *command_args])
|
||||
|
||||
def validate_temporary_profile(
|
||||
self, profile: str | os.PathLike[str], *, strict: bool = True
|
||||
) -> CommandResult:
|
||||
return self.validate(profile, strict=strict)
|
||||
|
||||
def show_temporary_profile(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||
return self.show(profile)
|
||||
|
||||
def run_temporary_profile(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
command: Sequence[str | os.PathLike[str]],
|
||||
*,
|
||||
allow_cwd: bool = False,
|
||||
) -> CommandResult:
|
||||
return self.run(
|
||||
profile,
|
||||
command,
|
||||
allow_cwd=allow_cwd,
|
||||
)
|
||||
|
||||
def _open_temporary_profile(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
) -> tuple[Path, int]:
|
||||
path = Path(os.path.normpath(os.fspath(profile)))
|
||||
if not path.is_absolute():
|
||||
raise ValueError("temporary profile must be an absolute path")
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("temporary profile must be a JSON file")
|
||||
if _has_symlink_component(path):
|
||||
raise ValueError("temporary profile cannot contain a symlinked path component")
|
||||
active_roots = self._active_profile_roots()
|
||||
if any(path.parent == root or root in path.parents for root in active_roots):
|
||||
raise ValueError("active Nono profile namespace is not permitted")
|
||||
try:
|
||||
descriptor = _open_path_nofollow(path)
|
||||
except OSError as error:
|
||||
raise ValueError(f"temporary profile is unavailable: {error}") from error
|
||||
file_stat = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
os.close(descriptor)
|
||||
raise ValueError("temporary profile must be a regular file")
|
||||
return path, descriptor
|
||||
|
||||
def _active_profile_roots(self) -> set[Path]:
|
||||
configured_home = self.environment["HOME"]
|
||||
return {
|
||||
(Path.home() / ".config" / "nono" / "profiles").resolve(),
|
||||
(
|
||||
Path(os.environ.get("HOME", str(Path.home())))
|
||||
/ ".config"
|
||||
/ "nono"
|
||||
/ "profiles"
|
||||
).resolve(),
|
||||
(Path(configured_home) / ".config" / "nono" / "profiles").resolve(),
|
||||
}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _profile_snapshot(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
) -> Iterator[Path]:
|
||||
_source, descriptor = self._open_temporary_profile(profile)
|
||||
try:
|
||||
source_stat = os.fstat(descriptor)
|
||||
if source_stat.st_size > 1024 * 1024:
|
||||
raise ValueError("temporary profile must be a regular file no larger than 1 MiB")
|
||||
for active_root in self._active_profile_roots():
|
||||
if not active_root.is_dir():
|
||||
continue
|
||||
for active_profile in active_root.glob("*.json"):
|
||||
try:
|
||||
active_stat = active_profile.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if (active_stat.st_dev, active_stat.st_ino) == (
|
||||
source_stat.st_dev,
|
||||
source_stat.st_ino,
|
||||
):
|
||||
raise ValueError(
|
||||
"temporary profile cannot alias an active Nono profile"
|
||||
)
|
||||
payload = b""
|
||||
while len(payload) <= source_stat.st_size:
|
||||
chunk = os.read(descriptor, min(65536, source_stat.st_size + 1 - len(payload)))
|
||||
if not chunk:
|
||||
break
|
||||
payload += chunk
|
||||
final_stat = os.fstat(descriptor)
|
||||
if (
|
||||
len(payload) != source_stat.st_size
|
||||
or final_stat.st_size != source_stat.st_size
|
||||
or final_stat.st_mtime_ns != source_stat.st_mtime_ns
|
||||
):
|
||||
raise ValueError("temporary profile changed while it was being read")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
scratch_root = Path(self.environment["TMPDIR"])
|
||||
with tempfile.TemporaryDirectory(prefix="nono-profile-", dir=scratch_root) as directory:
|
||||
os.chmod(directory, 0o700)
|
||||
snapshot = Path(directory) / "profile.json"
|
||||
snapshot.write_bytes(payload)
|
||||
os.chmod(snapshot, 0o600)
|
||||
yield snapshot
|
||||
|
||||
def _execute(self, argv: Iterable[str]) -> CommandResult:
|
||||
return run_bounded_command(
|
||||
argv,
|
||||
cwd=self.cwd,
|
||||
environment=self.environment,
|
||||
timeout=self.timeout,
|
||||
max_output=self.max_output,
|
||||
)
|
||||
|
||||
|
||||
def run_bounded_command(
|
||||
argv: Iterable[str],
|
||||
*,
|
||||
cwd: str | os.PathLike[str],
|
||||
environment: Mapping[str, str],
|
||||
timeout: float,
|
||||
max_output: int,
|
||||
) -> CommandResult:
|
||||
argument_list = tuple(str(value) for value in argv)
|
||||
if timeout <= 0 or max_output <= 0:
|
||||
raise ValueError("bounded commands require positive timeout and output limits")
|
||||
cwd_path = Path(cwd)
|
||||
if not cwd_path.is_absolute():
|
||||
raise ValueError("bounded command cwd must be absolute")
|
||||
selected_environment = _validated_environment(environment)
|
||||
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
||||
|
||||
def drain(stream: BinaryIO, name: str) -> None:
|
||||
with stream:
|
||||
while chunk := stream.read(65536):
|
||||
remaining = max_output - len(buffers[name])
|
||||
if remaining > 0:
|
||||
buffers[name].extend(chunk[:remaining])
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
argument_list,
|
||||
cwd=os.fspath(cwd),
|
||||
env=selected_environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
return CommandResult(
|
||||
argument_list,
|
||||
None,
|
||||
"",
|
||||
_bounded_text(str(error), max_output),
|
||||
)
|
||||
assert process.stdout is not None and process.stderr is not None
|
||||
threads = [
|
||||
threading.Thread(target=drain, args=(process.stdout, "stdout"), daemon=True),
|
||||
threading.Thread(target=drain, args=(process.stderr, "stderr"), daemon=True),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
timed_out = False
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise NonoError("timed-out process group did not terminate") from error
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
if thread.is_alive():
|
||||
raise NonoError("subprocess output pipe did not close")
|
||||
return CommandResult(
|
||||
argument_list,
|
||||
None if timed_out else process.returncode,
|
||||
bytes(buffers["stdout"]).decode("utf-8", errors="replace"),
|
||||
bytes(buffers["stderr"]).decode("utf-8", errors="replace"),
|
||||
timed_out,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_text(value: str, limit: int) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[: max(0, limit - 3)] + "..."
|
||||
|
||||
|
||||
def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]:
|
||||
selected = dict(environment)
|
||||
for name in _REQUIRED_PATH_ENV:
|
||||
value = selected.get(name)
|
||||
if not value or not Path(value).is_absolute():
|
||||
raise ValueError(f"the isolated environment requires an absolute {name}")
|
||||
path = Path(value)
|
||||
if _has_symlink_component(path):
|
||||
raise ValueError(f"the isolated environment forbids symlinked {name}")
|
||||
try:
|
||||
path_stat = path.stat()
|
||||
except OSError as error:
|
||||
raise ValueError(f"the isolated environment path is unavailable: {name}") from error
|
||||
if (
|
||||
not stat.S_ISDIR(path_stat.st_mode)
|
||||
or stat.S_IMODE(path_stat.st_mode) != 0o700
|
||||
or path_stat.st_uid != os.geteuid()
|
||||
):
|
||||
raise ValueError(
|
||||
f"the isolated environment requires caller-owned mode-0700 {name}"
|
||||
)
|
||||
unknown = sorted(set(selected) - _ALLOWED_ENVIRONMENT)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"environment variable is not in the isolated allowlist: " + unknown[0]
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def _open_path_nofollow(path: Path) -> int:
|
||||
if not path.is_absolute() or not path.name:
|
||||
raise ValueError("profile path must identify an absolute file")
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
directory_fd = os.open(path.anchor, directory_flags)
|
||||
try:
|
||||
for component in path.parts[1:-1]:
|
||||
next_fd = os.open(component, directory_flags, dir_fd=directory_fd)
|
||||
os.close(directory_fd)
|
||||
directory_fd = next_fd
|
||||
return os.open(
|
||||
path.name,
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_NONBLOCK", 0),
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _has_symlink_component(path: Path) -> bool:
|
||||
current = path
|
||||
while current.parent != current:
|
||||
if current.is_symlink():
|
||||
return True
|
||||
current = current.parent
|
||||
return current.is_symlink()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommandResult",
|
||||
"HostRequirementError",
|
||||
"NonoAdapter",
|
||||
"NonoError",
|
||||
"run_bounded_command",
|
||||
]
|
||||
Reference in New Issue
Block a user