diff --git a/docs/reference/agents.md b/docs/reference/agents.md index 93b1995e4..9da187676 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -536,6 +536,22 @@ the turn: | Seatbelt (macOS) | paths (as given and as resolved, `/tmp` being `/private/tmp`); with `online=False`, outgoing TCP to the proxy's port on `localhost` only, every other outgoing connection and the system resolver's socket refused, and listening allowed on `localhost`, which Seatbelt also matches for every address and every port | | proxy on loopback | with `online=False`, the only way out: handed to the CLI as `HTTPS_PROXY` and related variables, passing only `hosts` (ports 443 and 80 for a host without a port) | +What a fence writes (`write`) and its scratch (`tmp`) are reached where the fence is put up +(`Fence.ready`), by `hmz internal fence` and by an anchor's supervisor as it forks the agent: +each from `/` one name at a time (`Fence.reach`), the scratch made `0700` where it is missing, +and a symbolic link followed only in a directory that the fence does not write, that is not +beneath one it writes, and that is not `/tmp`, `/var/tmp`, `$TMPDIR` or the scratch -- told by +device and inode, however it is spelled. Landlock grants what was reached as the descriptors +opened; Seatbelt, the paths they were reached at. So `~/.claude` kept elsewhere through a link +in a home the fence does not write is followed, while a link where a fenced program could have +put one (a scratch, or a state directory, that an earlier turn replaced with a link out of the +wall) refuses the turn, exit 126: `hmz internal fence: : it is a link where a fenced +program could have made one, which no fence follows to write` (or `it leads through , a +link ...`). A fence that writes `/` follows every link. A path that cannot be reached (missing, +past a file, not searchable) is granted nothing, as before; on a Mac, a path not there yet +is granted by the path it would be reached at. The agent makes the directories its CLI keeps +its state in the same way before the turn, through no such link. + The minimum granted whatever the scopes is `LINUX_SYSTEM` and `LINUX_DEVICES` on Linux, and `DARWIN_SYSTEM` (`/usr`, `/bin`, `/sbin`, `/System`, `/private/var/select`, `/private/var/db/timezone` and the handful of files under `/etc` a program reads to start) and diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9988528cd..8a7732fce 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -836,7 +836,7 @@ hosts; `TMPDIR` set to the fence's scratch directory. | --- | --- | | the program's | it ran | | `2` | no `--policy`; no program (`no program given; try `hmz internal fence --policy=... -- claude``) | -| `126` | the fence cannot be put up here (no Landlock, or no way to cut TCP; on macOS, a process already inside a sandbox); the program never ran (`hmz internal fence: `) | +| `126` | the fence cannot be put up here (no Landlock, or no way to cut TCP; on macOS, a process already inside a sandbox; a path it writes leads through a link a fenced program could have made); the program never ran (`hmz internal fence: `) | ### `hmz internal hook` {#hmz-internal-hook} diff --git a/docs/reference/remote-execution.md b/docs/reference/remote-execution.md index 9e020ee38..0f7647c4e 100644 --- a/docs/reference/remote-execution.md +++ b/docs/reference/remote-execution.md @@ -769,7 +769,7 @@ what crosses is its levels, not its paths. | Arrangement | This machine | Target | | --- | --- | --- | -| Supervised | The agent process is walled in by Landlock before it runs, with the mirror granted at the workdir's level. Where `online=False`, a proxy served by the anchor process is its only way out, passing the fence's hosts; binds are limited to a kernel-chosen port and the fence's `listen` ports, on loopback. The supervisor, its link and the mirror are outside the wall. | Every command the agent runs is sent with `{local, user, system, online, tmp}`, `tmp` being the agent's scratch directory here. The serving half draws the fence again (`abroad.drawn`) around its exported directories, its own `$HOME` and its own minimum, and runs the command under its own `hmz internal fence` with its scratch at that same path: made `0700` there if missing and left afterwards, used where it is a directory of the serving user's own (not a link), and otherwise replaced by one made for the one command. A command that names the path, as Claude Code's shell commands name the file they write their working directory to, finds it. Where `online=False`, the command reaches no host. | +| Supervised | The agent process is walled in by Landlock before it runs, with the mirror granted at the workdir's level. Where `online=False`, a proxy served by the anchor process is its only way out, passing the fence's hosts; binds are limited to a kernel-chosen port and the fence's `listen` ports, on loopback. The supervisor, its link and the mirror are outside the wall. | Every command the agent runs is sent with `{local, user, system, online, tmp}`, `tmp` being the agent's scratch directory here. The serving half draws the fence again (`abroad.drawn`) around its exported directories, its own `$HOME` and its own minimum, and runs the command under its own `hmz internal fence` with its scratch at that same path: made `0700` there if missing, through no link a fenced command could have made, and left afterwards; used where it is a directory of the serving user's own (not a link), and otherwise replaced by one made for the one command. A command that names the path, as Claude Code's shell commands name the file they write their working directory to, finds it. Where `online=False`, the command reaches no host. | | Native | nothing | The CLI is run under `hmz internal fence` on the target with the levels, the hosts its model is at, the state it writes under its home (sent as `~/…`), the turn's credential directory, read access to its own install tree, and its `listen` ports. | | Afar | refused | refused | @@ -795,6 +795,7 @@ what crosses is its levels, not its paths. | agent construction, supervised | `Unfenced` | ` cannot be held to its permission: it is supervised on this machine, which has no Landlock[ ABI 4 and seccomp to cut the network]` | | first turn, supervised | `Unfenced` | `: cannot fence the commands the agent runs there: it needs Seatbelt on a Mac, or Landlock (Linux 5.13 or later, not refused by a container's seccomp profile)[, at ABI 4 (Linux 6.7) with seccomp, to cut the network]; grant the agent everything, or run it where it can be fenced` | | anchor start, supervised | `PermissionError` | `this machine cannot fence the agent: it needs Landlock[ ABI 4 and seccomp]` | +| anchor start, supervised | `ValueError` | `: it is a link where a fenced program could have made one, which no fence follows to write`: a path the fence writes, or the scratch, leads through a link in a directory a fence writes ([Agents › Enforcement](/reference/agents#enforcement)); nothing is started | | native turn | `PermissionError` | `the target cannot fence the CLI: it needs Landlock[ ABI 4 and seccomp]` | | a fenced command on the target | `PermissionError` | `this machine cannot fence a command: it needs Landlock[ ABI 4 and seccomp]`; on a macOS target, `… it needs Seatbelt, which a process already inside a sandbox cannot apply` | diff --git a/src/hmz/coganchor/agents/base.py b/src/hmz/coganchor/agents/base.py index 423e65995..1ad70b1b7 100644 --- a/src/hmz/coganchor/agents/base.py +++ b/src/hmz/coganchor/agents/base.py @@ -3752,12 +3752,16 @@ def _ready(fence: Fence) -> None: A grant of a path that is not there yet is no grant at all -- Landlock can hold only what exists -- so the directories a CLI keeps its state in, and the one its sessions are kept in, are made before it is spawned into a fence that would stop it making them itself on - its first run. + its first run. Made as the fence reaches them where it is put up, through no link a fenced + program could have made; one that leads through such a link is left for the fence to + refuse. """ for path in fence.write: if not os.path.lexists(path) and _directory(path): - with contextlib.suppress(OSError): - os.makedirs(path, mode=0o700, exist_ok=True) # noqa: PTH103 + with contextlib.suppress(OSError, ValueError): + held, _ = fence.reach(path, make=True) + if held is not None: + os.close(held) def _directory(path: str) -> bool: diff --git a/src/hmz/coganchor/anchor.py b/src/hmz/coganchor/anchor.py index a7113505c..a0dfaf07c 100644 --- a/src/hmz/coganchor/anchor.py +++ b/src/hmz/coganchor/anchor.py @@ -807,6 +807,8 @@ class _Walls: Raises: PermissionError: If this machine cannot put the wall up. + ValueError: If a path it writes, or its scratch, leads through a link a fenced program + could have made (:meth:`~hmz.coganchor.fence.Fence.reach`). """ def __init__(self, fence: Fence, mirror: str) -> None: @@ -826,13 +828,15 @@ def __init__(self, fence: Fence, mirror: str) -> None: ) self._made = not fence.tmp self.tmp = fence.tmp or tempfile.mkdtemp(prefix="hmz-fence-") - os.makedirs(self.tmp, mode=0o700, exist_ok=True) # With the scratch the agent is given, for the target to keep its own at the same # path: the agent names it in the commands it sends there. self.told = abroad.told( replace(fence, tmp=self.tmp), home=os.path.expanduser("~"), native=False ) self._proxy = None if fence.online else Proxy(fence.hosts) + # Made and opened here, before the agent is forked, and granted as the very files + # opened: never a link an earlier turn put at one of their names. + self._held = replace(self.fence, tmp=self.tmp).ready() def around(self, launch: Launch) -> Launch: """The launch, walled in, with the environment a fenced program runs with.""" @@ -845,7 +849,7 @@ def around(self, launch: Launch) -> Launch: env=environ(self.fence, launch.env, tmp=self.tmp, port=port), walled=landlock.Ruleset( read=self.fence.read, - write=(*self.fence.write, self.tmp), + held=[fd for fd in self._held.values() if fd is not None], connect_ports=() if port is None else (port,), # As `hmz internal fence` has them: a port the kernel picks, and the ones the # fence names; each held to loopback by the supervisor (see `Launch`). @@ -864,6 +868,10 @@ def stop(self) -> None: """Stops the proxy, and takes away the scratch this made.""" import shutil + for fd in self._held.values(): + if fd is not None: + os.close(fd) + self._held = {} if self._proxy is not None: self._proxy.stop() if self._made: diff --git a/src/hmz/coganchor/fence/__init__.py b/src/hmz/coganchor/fence/__init__.py index 49e1e14b6..b40297987 100644 --- a/src/hmz/coganchor/fence/__init__.py +++ b/src/hmz/coganchor/fence/__init__.py @@ -34,10 +34,13 @@ from __future__ import annotations +import contextlib import dataclasses +import errno import importlib import json import os +import stat import sys from dataclasses import dataclass from pathlib import Path @@ -221,6 +224,49 @@ def _users() -> tuple[str, ...]: return (*_temporary(), f"/run/user/{uid}") +#: How a directory on the way to a path a fence writes is opened: to search it, never to read +#: it, where this machine can tell the two apart. +_SEARCH: Final = ( + getattr(os, "O_PATH", getattr(os, "O_SEARCH", os.O_RDONLY)) | os.O_CLOEXEC +) + +#: How many links one path may lead through, as Linux counts them (`MAXSYMLINKS`). +_LINKS: Final = 40 + +#: Why a path a fence writes is granted nothing rather than refused, as Landlock leaves out a +#: path it cannot reach. +_UNREACHED: Final = frozenset({errno.ENOENT, errno.ENOTDIR, errno.EACCES, errno.ELOOP}) + + +def _identity(held: os.stat_result) -> tuple[int, int]: + return held.st_dev, held.st_ino + + +def _names(path: str) -> list[str]: + """The names a path is looked up by, in order, `..` kept as the kernel keeps it.""" + return [one for one in Path(path).parts if one != os.sep] + + +def _swapped(was: int, now: int) -> int: + os.close(was) + return now + + +def _held(name: str, directory: int, seen: os.stat_result) -> int | None: + """A descriptor holding what was looked at, where this machine opens one to grant. + + Opened without following a link: one swapped in since it was looked at is not what was + looked at, and is refused. + """ + if not hasattr(os, "O_PATH"): + return None + fd = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory) + if _identity(os.fstat(fd)) != _identity(seen): + os.close(fd) + raise OSError(errno.ELOOP, "it was replaced while it was reached", name) + return fd + + def _rank(level: str) -> int: try: return LEVELS.index(str(level)) @@ -480,6 +526,139 @@ def allows(self, path: str | os.PathLike[str], *, write: bool = False) -> bool: roots = self.write if write else (*self.write, *self.read) return any(_under(held, root) for root in roots) + def ready(self) -> dict[str, int | None]: + """Makes the scratch where it is missing, and reaches everything this fence writes. + + What is put up where the fence is, just before the program is walled in: each path + in :attr:`write` and the scratch, reached as :meth:`reach` reaches it, so that what + is granted is what was checked and never a name an earlier turn put a link at. A + fence that writes `/` writes whatever any link leads to, and is reached as `/`. + + Returns: + Each path as it was reached, every link on the way already followed, with a + descriptor holding it where this machine opens one to grant (`O_PATH`, Linux); + None where it does not, or where it is not there yet. A path that cannot be + reached is left out, and so granted nothing. + + Raises: + ValueError: If a path leads through a link a fenced program could have made. + OSError: If the scratch cannot be made or reached. + """ + roots = (os.sep,) if os.sep in self.write else self.write + reached: dict[str, int | None] = {} + try: + for path in (*roots, *((self.tmp,) if self.tmp else ())): + try: + fd, at = self.reach(path, make=path == self.tmp) + except OSError as why: + if path == self.tmp or why.errno not in _UNREACHED: + raise + continue + if (was := reached.get(at)) is not None: + os.close(was) + reached[at] = fd + except BaseException: + for fd in reached.values(): + if fd is not None: + os.close(fd) + raise + return reached + + def reach(self, path: str, *, make: bool = False) -> tuple[int | None, str]: + """Reaches a path this fence writes from `/`, one name at a time, as checked. + + A symbolic link is followed only where no fenced program could have made it: in a + directory that neither this fence writes, nor holds one it writes, nor is + :data:`TEMPORARY`, `$TMPDIR` or the scratch -- told by device and inode, however it + is spelled. So `~/.claude` kept elsewhere through a link in a home the fence does not + write is followed, while a scratch or a state directory an earlier turn replaced with + a link out of the wall is refused. A fence that writes `/` follows every link. + + Args: + path: The absolute path. + make: Whether to make what is missing on the way, `0700`. + + Returns: + A descriptor holding what was reached (`O_PATH`, on Linux only), or None where this + machine has no `O_PATH` or the last name is not there; and the path it was reached + at, links followed -- or would be, where it is not there yet and this machine + grants a name (a Mac). + + Raises: + ValueError: If it leads through a link a fenced program could have made. + OSError: If it cannot be reached: missing short of the last name, not made, where + this machine grants only what is there (Linux); past a file; or not searchable. + """ + todo = _names(path) + at: list[str] = [] + fd = os.open(os.sep, _SEARCH) + chain = [_identity(os.fstat(fd))] + links = 0 + try: + while todo: + name = todo.pop(0) + if name == "..": + if at: + fd = _swapped(fd, os.open("..", _SEARCH, dir_fd=fd)) + at.pop() + chain.pop() + continue + try: + held = os.stat(name, dir_fd=fd, follow_symlinks=False) + except FileNotFoundError: + if not make: + # Landlock grants only what is there; Seatbelt, a name to be made. + if todo and hasattr(os, "O_PATH"): + raise + return None, os.path.normpath(Path(os.sep, *at, name, *todo)) + with contextlib.suppress(FileExistsError): + os.mkdir(name, 0o700, dir_fd=fd) + todo.insert(0, name) + continue + if stat.S_ISLNK(held.st_mode): + if os.sep not in self.write and self._written(chain): + through = ( + "it is a link" + if not todo and not links + else f"it leads through {Path(os.sep, *at, name)}, a link" + ) + raise ValueError( + f"{path}: {through} where a fenced program could have made " + "one, which no fence follows to write" + ) + links += 1 + if links > _LINKS: + raise OSError(errno.ELOOP, os.strerror(errno.ELOOP), path) + led = os.readlink(name, dir_fd=fd) + if led.startswith(os.sep): + fd = _swapped(fd, os.open(os.sep, _SEARCH)) + at, chain = [], chain[:1] + todo[:0] = _names(led) + continue + if not todo: + return _held(name, fd, held), str(Path(os.sep, *at, name)) + # Not following a link swapped in since it was looked at: that fails here. + fd = _swapped( + fd, + os.open(name, _SEARCH | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd), + ) + at.append(name) + chain.append(_identity(os.fstat(fd))) + return _held(".", fd, os.fstat(fd)), str(Path(os.sep, *at)) + finally: + os.close(fd) + + def _written(self, chain: Iterable[tuple[int, int]]) -> bool: + """Whether a fenced program could change a directory, given it and those above it. + + The directories it writes looked up as they stand now, a root made since included. + """ + written: set[tuple[int, int]] = set() + for root in (*self.write, *_temporary(), *((self.tmp,) if self.tmp else ())): + with contextlib.suppress(OSError): + written.add(_identity(Path(root).stat())) + return any(one in written for one in chain) + def dumps(self) -> str: """This fence as JSON, which is what ``hmz internal fence --policy`` takes.""" return json.dumps(dataclasses.asdict(self), separators=(",", ":")) diff --git a/src/hmz/coganchor/fence/abroad.py b/src/hmz/coganchor/fence/abroad.py index 1975ecd58..8acd2fbe6 100644 --- a/src/hmz/coganchor/fence/abroad.py +++ b/src/hmz/coganchor/fence/abroad.py @@ -183,7 +183,8 @@ def ready(fence: Fence, home: str) -> None: -- so the state a CLI keeps under its home is made before it is walled in, or a CLI that has never run on this machine could not make it on its first turn. Only directories, read off the name as the machine the turn was started from reads them: `~/.cli` is one, - `~/.cli.json` is a file. + `~/.cli.json` is a file. Made as the fence reaches them where it is put up, through no + link a fenced program could have made (:meth:`Fence.reach`). Args: fence: The fence, as :func:`drawn` drew it. @@ -192,9 +193,11 @@ def ready(fence: Fence, home: str) -> None: root = os.path.normpath(home).rstrip(os.sep) + os.sep for one in fence.write: name = Path(one).name.lstrip(".") - if one.startswith(root) and "." not in name and not Path(one).exists(): - with contextlib.suppress(OSError): - Path(one).mkdir(mode=0o700, parents=True, exist_ok=True) + if one.startswith(root) and "." not in name and not os.path.lexists(one): + with contextlib.suppress(OSError, ValueError): + held, _ = fence.reach(one, make=True) + if held is not None: + os.close(held) def installed(program: str, path: str | None = None) -> list[str]: diff --git a/src/hmz/coganchor/fence/wrap.py b/src/hmz/coganchor/fence/wrap.py index 474305118..ef93512d5 100644 --- a/src/hmz/coganchor/fence/wrap.py +++ b/src/hmz/coganchor/fence/wrap.py @@ -31,6 +31,7 @@ import contextlib import ctypes +import dataclasses import errno import importlib import os @@ -133,7 +134,9 @@ def run(fence: Fence, argv: Sequence[str]) -> int: not be put up and the program never ran. Raises: - ValueError: If there is no program to run. + ValueError: If there is no program to run, or a path the fence writes, or its + scratch, leads through a link a fenced program could have made (:meth:`Fence.reach`). + OSError: If the scratch cannot be made or reached. RuntimeError: If this host cannot enforce the fence, said before anything is started. """ from hmz.coganchor.fence import enforceable @@ -157,10 +160,13 @@ def run(fence: Fence, argv: Sequence[str]) -> int: libc = ctypes.CDLL(None, use_errno=True) made = not fence.tmp tmp = fence.tmp or tempfile.mkdtemp(prefix="hmz-fence-") - Path(tmp).mkdir(mode=0o700, parents=True, exist_ok=True) proxy = None if fence.online else Proxy(fence.hosts) supervisor: Supervisor | None = None + held: dict[str, int | None] = {} try: + # Made and opened here, where it is put up, and handed down open: what the child + # grants is what was checked, never a link put at one of its names since. + held = dataclasses.replace(fence, tmp=tmp).ready() if proxy is not None: proxy.start() if libc.prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0: @@ -172,7 +178,7 @@ def run(fence: Fence, argv: Sequence[str]) -> int: ours, theirs = socket.socketpair() if proxy is not None else (None, None) pid = os.fork() if not pid: - _become(fence, argv, env, tmp, (port, theirs), (parent, libc)) + _become(fence, held, argv, env, (port, theirs), (parent, libc)) if ours is not None and theirs is not None: theirs.close() with ours: @@ -186,6 +192,9 @@ def run(fence: Fence, argv: Sequence[str]) -> int: supervisor.start() return _status(_waited(pid)) finally: + for fd in held.values(): + if fd is not None: + os.close(fd) if supervisor is not None: supervisor.stop() if proxy is not None: @@ -205,9 +214,11 @@ def _seatbelted(fence: Fence, argv: Sequence[str]) -> int: made = not fence.tmp tmp = fence.tmp or tempfile.mkdtemp(prefix="hmz-fence-") - Path(tmp).mkdir(mode=0o700, parents=True, exist_ok=True) proxy = None if fence.online else Proxy(fence.hosts) try: + # Granted as the paths they were reached at, every link on the way followed where + # it may be: a link put at one of them since leads nowhere granted. + reached = tuple(dataclasses.replace(fence, tmp=tmp).ready()) if proxy is not None: proxy.start() port = proxy.port if proxy is not None else None @@ -218,7 +229,7 @@ def _seatbelted(fence: Fence, argv: Sequence[str]) -> int: # program only reads the keychains. Denied, TLS reports a bad certificate. security = (str(Path(cache, "mds")),) profile = seatbelt.Profile( - read=fence.read, write=(*fence.write, tmp, *security), port=port + read=fence.read, write=(*reached, *security), port=port ) pid = os.posix_spawn( seatbelt.SANDBOX_EXEC, @@ -242,9 +253,9 @@ def _status(status: int) -> int: def _become( fence: Fence, + held: Mapping[str, int | None], argv: Sequence[str], env: dict[str, str], - tmp: str, offline: tuple[int | None, socket.socket | None], orphaned: tuple[int, ctypes.CDLL], ) -> None: @@ -286,7 +297,7 @@ def _become( handoff.close() landlock.Ruleset( read=fence.read, - write=(*fence.write, tmp), + held=[fd for fd in held.values() if fd is not None], connect_ports=() if port is None else (port,), # A port the kernel picks is a listener only, which reaches nothing: a CLI that # serves itself on loopback (agy's language server) cannot start without one. A diff --git a/src/hmz/coganchor/linux/landlock.py b/src/hmz/coganchor/linux/landlock.py index 5f8234bc4..3c3fe9725 100644 --- a/src/hmz/coganchor/linux/landlock.py +++ b/src/hmz/coganchor/linux/landlock.py @@ -197,6 +197,9 @@ class Ruleset: read: Paths that may be read, listed and executed. write: Paths that may be read and changed in every way: created, written, truncated, renamed, removed. + held: Files and directories that may be changed as `write` ones may, given as + descriptors the caller opened and checked: granted as the very files they are, + never looked up again by a name something could have put a link at since. connect_ports: TCP ports that may be connected to, on any address. bind_ports: TCP ports that may be bound. net: Whether TCP is governed at all. Without it every connect and bind is left @@ -205,6 +208,7 @@ class Ruleset: read: Iterable[str | os.PathLike[str]] = () write: Iterable[str | os.PathLike[str]] = () + held: Iterable[int] = () connect_ports: Iterable[int] = () bind_ports: Iterable[int] = () net: bool = False @@ -250,6 +254,8 @@ def restrict_self(self) -> None: for paths, write in ((self.read, False), (self.write, True)): for path in paths: _add_path(ruleset, path, version, write=write) + for fd in self.held: + _add_held(ruleset, fd, version, write=True) for ports, right in ((connect, _NET_CONNECT_TCP), (bind, _NET_BIND_TCP)): for port in ports: _add_rule(ruleset, _RULE_NET_PORT, struct.pack(" None: + """Grant the file or directory a descriptor holds, as it is.""" + directory = stat.S_ISDIR(os.fstat(fd).st_mode) + rights = fs_rights(version, write=write, directory=directory) + _add_rule(ruleset, _RULE_PATH_BENEATH, struct.pack(" bool: +def _scratch(fence: Fence) -> bool: """Makes the scratch a supervised agent names in its commands, at the path it names. The agent was given it as its temporary directory on the machine it runs on, and writes @@ -113,23 +113,26 @@ def _scratch(path: str) -> bool: cannot be written ends with a status of 1 whatever it did. So it is made here too, at the same path, for every command of the agent to share -- and left, as a CLI run with no fence leaves what it writes under `/tmp`. Made once and never taken over: a path that is - there already is used only where it is a directory of this user's own, and not through a - link. + there already is used only where it is a directory of this user's own, and reached + through no link a fenced command could have made (:meth:`Fence.reach`). Args: - path: The path, as the agent's machine names it. + fence: The fence the command runs in, whose `tmp` is the path as the agent's machine + names it. Returns: Whether it is there to be used. Where it is not, the command is given a scratch of its own for the one run, as a command was before its agent's was known here. """ - with contextlib.suppress(OSError): - os.makedirs(path, mode=0o700) try: - held = os.lstat(path) - except OSError: + # Nothing made at the far end of a link that is there already. + held, _ = fence.reach(fence.tmp, make=not os.path.lexists(fence.tmp)) + if held is not None: + os.close(held) + seen = os.lstat(fence.tmp) + except (OSError, ValueError): return False - return stat.S_ISDIR(held.st_mode) and held.st_uid == os.geteuid() + return stat.S_ISDIR(seen.st_mode) and seen.st_uid == os.geteuid() def _named(argv: list[str]) -> str: diff --git a/tests/integration/test_anchor_fence_writes.py b/tests/integration/test_anchor_fence_writes.py new file mode 100644 index 000000000..f40817d0f --- /dev/null +++ b/tests/integration/test_anchor_fence_writes.py @@ -0,0 +1,100 @@ +"""What a fence writes, reached and checked where it is put up again. + +An agent writes the directory its scratch is in -- the temporary directory a workdir it writes +brings with it, or as here, a state directory of its own -- and so may put its scratch aside +in one turn and leave a link out of the wall at its name. Each test runs the agent for real, +under `hmz internal fence` or walled in by the supervisor that traces it, for two turns of the +same fence: the second is where a grant made by name would follow that link. +""" + +from __future__ import annotations + +import dataclasses +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest + +from hmz.coganchor.fence import NONE, READ, Fence, enforceable, landlocked +from hmz.coganchor.providers import redirect + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.skipif( + not enforceable(net=False), reason="this machine has nothing to fence with" +) + + +def _turn( + put_up: str, fence: Fence, work: Path, script: str +) -> subprocess.CompletedProcess[str]: + """One turn of an agent that is this Python running `script`, inside the fence.""" + if put_up == "wrapper": + around = ["internal", "fence", f"--policy={fence.dumps()}", "--"] + else: + around = [ + "internal", + "anchor", + "--target", + "local", + "--workspace", + str(work), + f"--fence={fence.dumps()}", + ] + return subprocess.run( + [sys.executable, "-Pm", "hmz", *around, sys.executable, "-I", "-c", script], + capture_output=True, + text=True, + timeout=120, + check=False, + cwd=work, + ) + + +@pytest.mark.parametrize( + "put_up", + [ + "wrapper", + pytest.param( + "supervisor", + marks=pytest.mark.skipif( + not landlocked(net=False) or not redirect.supervises(), + reason="this machine supervises nothing", + ), + ), + ], +) +def test_a_scratch_swapped_for_a_link_out_is_refused_where_the_fence_is_put_up_again( + tmp_path: Path, put_up: str +) -> None: + tmp_path = tmp_path.resolve() + home, work, state = tmp_path / "home", tmp_path / "work", tmp_path / "state" + for one in (home, work, state): + one.mkdir() + scratch = state / "scratch" + # The workdir read, the home kept out, and written only what the agent keeps of its own: + # its state, and its scratch in it. + fence = dataclasses.replace( + Fence.of( + local=READ, user=NONE, system=NONE, online=True, workdir=work, home=home + ).granting(write=[state]), + tmp=str(scratch), + ) + swapped = _turn( + put_up, + fence, + work, + f"import os; os.rename({str(scratch)!r}, {str(state / 'aside')!r}); " + f"os.symlink({str(home)!r}, {str(scratch)!r})", + ) + assert swapped.returncode == 0, swapped.stderr + + # The next turn's fence reaches its scratch through no link a fenced program could have + # made: refused before anything runs, and nothing written into the home. + said = _turn(put_up, fence, work, f"open({str(scratch / 'x')!r}, 'w').write('x')") + + assert said.returncode != 0 + assert "a link where a fenced program could have made one" in said.stderr + assert not (home / "x").exists() diff --git a/tests/unit/coganchor/fence/test_fence.py b/tests/unit/coganchor/fence/test_fence.py index 3c67e83f1..d14fa3ebf 100644 --- a/tests/unit/coganchor/fence/test_fence.py +++ b/tests/unit/coganchor/fence/test_fence.py @@ -8,6 +8,8 @@ import dataclasses import itertools +import os +import stat import sys import types from pathlib import Path @@ -458,3 +460,103 @@ def landlocked(*, net: bool) -> bool: monkeypatch.setattr(fencing, "landlocked", landlocked) assert enforceable(net=net) is net + + +@pytest.fixture +def reachable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A directory no temporary directory holds, as a home would be.""" + monkeypatch.setattr(fencing, "TEMPORARY", ()) + monkeypatch.setenv("TMPDIR", str(tmp_path / "elsewhere")) + return tmp_path.resolve() + + +def _closed(reached: tuple[int | None, str]) -> str: + fd, at = reached + if fd is not None: + os.close(fd) + return at + + +def test_reach_refuses_a_link_where_a_fenced_program_could_have_made_it( + reachable: Path, +) -> None: + state, home = reachable / "state", reachable / "home" + state.mkdir() + home.mkdir() + (state / "scratch").symlink_to(home) + fence = dataclasses.replace(Fence(write=(str(state),)), tmp=str(state / "scratch")) + + with pytest.raises(ValueError, match="it is a link where a fenced program could"): + fence.reach(str(state / "scratch"), make=True) + with pytest.raises(ValueError, match="a link where a fenced program could"): + fence.ready() + + +def test_reach_refuses_a_link_on_the_way_in_a_written_directory( + reachable: Path, +) -> None: + work, home = reachable / "work", reachable / "home" + work.mkdir() + home.mkdir() + (work / "state").symlink_to(home) + fence = Fence(write=(str(work), str(work / "state" / "own"))) + + with pytest.raises(ValueError, match=r"it leads through .*/work/state, a link"): + fence.reach(str(work / "state" / "own"), make=True) + assert not (home / "own").exists() + + +def test_reach_follows_a_link_in_a_directory_no_fence_writes(reachable: Path) -> None: + home, kept = reachable / "home", reachable / "kept" + home.mkdir() + kept.mkdir() + (home / ".claude").symlink_to(kept) + + at = _closed(Fence(write=(str(home / ".claude"),)).reach(str(home / ".claude"))) + + assert at == str(kept) + + +def test_reach_follows_every_link_for_a_fence_that_writes_everything( + reachable: Path, +) -> None: + (reachable / "kept").mkdir() + (reachable / "link").symlink_to(reachable / "kept") + + at = _closed(Fence(write=(os.sep, str(reachable))).reach(str(reachable / "link"))) + + assert at == str(reachable / "kept") + + +def test_reach_makes_what_is_missing_as_the_users_own(reachable: Path) -> None: + made = reachable / "a" / "b" + + at = _closed(Fence().reach(str(made), make=True)) + + assert at == str(made) + assert made.is_dir() + assert stat.S_IMODE(made.stat().st_mode) == 0o700 + + +def test_ready_grants_nothing_past_what_is_not_there(reachable: Path) -> None: + there, file = reachable / "there", reachable / "file" + there.mkdir() + file.write_text("") + fence = Fence( + write=( + str(there), + str(there / "new.json"), + str(file / "under"), + str(reachable / "x" / "y"), + ) + ) + + reached = fence.ready() + for fd in reached.values(): + if fd is not None: + os.close(fd) + + # Seatbelt grants a name to be made, where Landlock grants only what is there. + named = () if hasattr(os, "O_PATH") else (str(reachable / "x" / "y"),) + assert set(reached) == {str(there), str(there / "new.json"), *named} + assert reached[str(there / "new.json")] is None diff --git a/tests/unit/coganchor/serve/test_fencing.py b/tests/unit/coganchor/serve/test_fencing.py index 94998a9e5..01f6265bb 100644 --- a/tests/unit/coganchor/serve/test_fencing.py +++ b/tests/unit/coganchor/serve/test_fencing.py @@ -30,12 +30,6 @@ def enforceable(*, net: bool) -> bool: assert fencing.able() == {"fs": fs, "net": cuts} -@dataclass -class Drawn: - tmp: str = "" - said: dict[str, Any] = field(default_factory=dict[str, Any]) - - @dataclass class Fake: """The fence and its drawing abroad, recording what they were asked.""" @@ -47,6 +41,8 @@ class Fake: ) readied: list[str] = field(default_factory=list[str]) tmp: str = "" + write: tuple[str, ...] = () + wrapped: list[fence.Fence] = field(default_factory=list[fence.Fence]) @pytest.fixture @@ -59,11 +55,11 @@ def drawn( workdirs: Sequence[str], home: str, read: Sequence[str], - ) -> Drawn: + ) -> fence.Fence: held.drawn.append( {"workdirs": list(workdirs), "home": home, "read": list(read)} ) - return Drawn(tmp=held.tmp, said=dict(said)) + return fence.Fence(write=held.write, tmp=held.tmp) def installed(program: str, path: str | None = None) -> list[str]: held.looked_up.append((program, path)) @@ -72,7 +68,8 @@ def installed(program: str, path: str | None = None) -> list[str]: def enforceable(*, net: bool) -> bool: return held.able - def wrapper(fenced: object) -> list[str]: + def wrapper(fenced: fence.Fence) -> list[str]: + held.wrapped.append(fenced) return ["hmz", "internal", "fence", "--"] def ready(fenced: object, home: str) -> None: @@ -146,3 +143,18 @@ def test_a_scratch_directory_is_made_where_it_is_named( fake.tmp = str(tmp_path / "scratch") fencing.fenced({}, table, ["ls"], None, {}) assert (tmp_path / "scratch").is_dir() + + +def test_a_scratch_reached_through_a_link_a_command_could_have_made_is_not_used( + fake: Fake, table: ExportTable, tmp_path: Path +) -> None: + written, elsewhere = tmp_path / "written", tmp_path / "elsewhere" + written.mkdir() + elsewhere.mkdir() + (written / "scratch").symlink_to(elsewhere) + fake.write, fake.tmp = (str(written),), str(written / "scratch") + + fencing.fenced({}, table, ["ls"], None, {}) + + assert fake.wrapped[0].tmp == "" + assert list(elsewhere.iterdir()) == []