Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/reference/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>: it is a link where a fenced
program could have made one, which no fence follows to write` (or `it leads through <link>, 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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <why>`) |
| `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: <why>`) |

### `hmz internal hook` {#hmz-internal-hook}

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/remote-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -795,6 +795,7 @@ what crosses is its levels, not its paths.
| agent construction, supervised | `Unfenced` | `<Agent> 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` | `<id>: <target> 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` | `<path>: 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` |

Expand Down
10 changes: 7 additions & 3 deletions src/hmz/coganchor/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions src/hmz/coganchor/anchor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand All @@ -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`).
Expand All @@ -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:
Expand Down
179 changes: 179 additions & 0 deletions src/hmz/coganchor/fence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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=(",", ":"))
Expand Down
Loading
Loading