nisse-core: group modules into packages by architecture section
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m37s
CI / e2e (pull_request) Successful in 4m5s

A pure move; behaviour unchanged.

- models (L9, L7): llm, catalog, probe, render, render_anthropic, context
- log (L7, L6): canonical, views
- state (L8): store (was state), reconcile, worker, cancel, dump
- knowledge (L10, L6.4): vault, memory
- conversation (L5): transport, matrix
- execution (L13): ssh (was execution), testing
- authority (P3, P4, L4): actions
- gitea, oplog, envfile stay at the top until each gains a second module.

Every import, monkeypatch target, script and doc reference rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-13 21:16:09 +00:00
1 parent 06759d5d70
commit e351d0a74e
117 files changed
+291 -265

No files matched your search

@@ -0,0 +1 @@
"""Running commands on tool VMs over SSH (architecture L13)."""
+232
View File
@@ -0,0 +1,232 @@
"""Running commands on a tool VM over SSH (architecture L13).
``SSHHost.execute`` is the only way the agent runs anything: there is no
local execution path, so the host the agent lives on never runs a command a
model chose. The harness binds the host; a model never names one.
How a command travels. The remote side runs ``bash -s`` and reads a small
script from SSH's stdin: the secrets handed to this one call as ``export``
lines, a ``cd``, then the command under ``timeout``. Nothing secret is ever on
a command line (``ps`` and ``/proc/*/cmdline`` would show it); the values exist
only in that shell and the command's environment. The command's own stdin is
``/dev/null``, or a here-document carrying ``stdin``, so it can never read the
rest of the script.
Every value handed out is redacted from what comes back (L12): a test that
prints its environment must not put a key into the transcript.
The connection is reused across calls (OpenSSH's ControlMaster), so a tool
call costs one round trip, not a handshake.
"""
import os
import secrets as _random
import shlex
import subprocess
import tempfile
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
# The remote command's exit code is passed back as is, except that 255 is
# what ssh itself uses for "could not connect"; a command exiting 255 is
# reported as 254 so the two can't be confused.
SSH_FAILED = 255
_TIMED_OUT = 124 # coreutils `timeout`
_KILL_GRACE_S = 5
# The local backstop, beyond the remote timeout, for a connection that hangs.
_LOCAL_SLACK_S = 30
REDACTED = "[redacted]"
class HostUnavailable(RuntimeError):
"""The host could not be reached: not something the command did."""
class HostKeyChanged(HostUnavailable):
"""The host answered with a different key than the one pinned: rebuilt,
or not the host it claims to be. Only the operator may trust the new
key (M4.1)."""
@dataclass(frozen=True)
class Result:
exit_code: int
stdout: str
stderr: str
timed_out: bool = False
@property
def ok(self) -> bool:
return self.exit_code == 0 and not self.timed_out
def redact(text: str, values: Iterable[str]) -> str:
"""Mask every non-trivial secret value in ``text``. Longest first, so a
value containing another is masked whole."""
for value in sorted((v for v in values if len(v) >= 4), key=len, reverse=True):
text = text.replace(value, REDACTED)
return text
def _heredoc(body: str) -> tuple[str, str]:
"""A here-document redirection whose delimiter can't occur in ``body``."""
while True:
tag = f"NISSE_EOF_{_random.token_hex(8)}"
if tag not in body:
return f"<<'{tag}'", f"\n{body}\n{tag}"
def _script(
command: str,
cwd: str | None,
secrets: Mapping[str, str],
stdin: str | None,
timeout_s: int,
) -> str:
lines = [f"export {name}={shlex.quote(value)}" for name, value in secrets.items()]
if cwd is not None:
lines.append(f"cd -- {shlex.quote(cwd)} || exit 125")
redirect, body = ("< /dev/null", "") if stdin is None else _heredoc(stdin)
# `timeout` runs the command in its own process group and signals the
# whole group, so a backgrounded grandchild doesn't outlive it.
lines.append(
f"timeout --kill-after={_KILL_GRACE_S} {timeout_s} "
f"bash -c {shlex.quote(command)} {redirect}{body}"
)
lines.append("rc=$?; [ $rc -eq 255 ] && rc=254; exit $rc")
return "\n".join(lines) + "\n"
class SSHHost:
"""One tool VM, reached as ``user@address`` with a private key."""
def __init__(
self,
address: str,
*,
key: Path,
state_dir: Path,
user: str = "root",
port: int = 22,
) -> None:
self.address = address
self.user = user
self.port = port
self._key = Path(key)
state_dir = Path(state_dir)
state_dir.mkdir(parents=True, exist_ok=True)
self._known_hosts = state_dir / "known_hosts"
# A Unix socket's path is limited to about 100 bytes, so the shared
# connection's socket lives in a short private directory rather than
# beside the state, named by %C, ssh's hash of the connection.
sockets = Path(tempfile.gettempdir()) / f"nisse-ssh-{os.getuid()}"
sockets.mkdir(mode=0o700, exist_ok=True)
self._control = sockets / "%C"
def describe(self) -> str:
return f"{self.user}@{self.address}"
def _ssh(self) -> list[str]:
return [
"ssh",
"-i",
str(self._key),
"-p",
str(self.port),
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
f"UserKnownHostsFile={self._known_hosts}",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ControlMaster=auto",
"-o",
f"ControlPath={self._control}",
"-o",
"ControlPersist=120",
"-o",
"ConnectTimeout=15",
"-o",
"ServerAliveInterval=15",
"-o",
"LogLevel=ERROR",
f"{self.user}@{self.address}",
"bash -s",
]
def execute(
self,
command: str,
*,
cwd: str | None = None,
secrets: Mapping[str, str] | None = None,
stdin: str | None = None,
timeout_s: int = 120,
) -> Result:
"""Run ``command`` with bash in ``cwd``. ``secrets`` become environment
variables of this one command and are redacted from its output.
Raises HostUnavailable when the host can't be reached."""
handed_out = dict(secrets or {})
script = _script(command, cwd, handed_out, stdin, timeout_s)
try:
completed = subprocess.run(
self._ssh(),
input=script.encode(),
capture_output=True,
timeout=timeout_s + _KILL_GRACE_S + _LOCAL_SLACK_S,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise HostUnavailable(
f"{self.describe()} stopped responding during a command"
) from exc
except OSError as exc:
raise HostUnavailable(f"could not run ssh: {exc}") from exc
stdout = completed.stdout.decode(errors="replace")
stderr = completed.stderr.decode(errors="replace")
if completed.returncode == SSH_FAILED and (
"IDENTIFICATION HAS CHANGED" in stderr
or "Host key verification failed" in stderr
):
raise HostKeyChanged(
f"{self.describe()}'s host key changed since it was pinned"
)
if completed.returncode == SSH_FAILED:
raise HostUnavailable(
f"could not reach {self.describe()}: "
+ redact(stderr.strip() or "ssh failed", handed_out.values())
)
values = handed_out.values()
return Result(
exit_code=completed.returncode,
stdout=redact(stdout, values),
stderr=redact(stderr, values),
timed_out=completed.returncode in (_TIMED_OUT, 137),
)
def forget_host_key(self) -> None:
"""Drop the pinned key, so the next connection pins the host's new
one. Only on the operator's word (M4.1)."""
self.close()
name = self.address if self.port == 22 else f"[{self.address}]:{self.port}"
subprocess.run(
["ssh-keygen", "-R", name, "-f", str(self._known_hosts)],
capture_output=True,
timeout=10,
check=False,
)
def close(self) -> None:
"""End the shared connection, if one is open."""
args = self._ssh()[:-2] # without the destination and the command
subprocess.run(
[*args, "-O", "exit", f"{self.user}@{self.address}"],
capture_output=True,
timeout=10,
check=False,
)
@@ -0,0 +1,101 @@
"""Test support: a throwaway sshd standing in for a tool VM (L13).
Runs the system's sshd as the current user, on a free local port, with a
host key and client key made for the occasion. Tests then reach "the build
VM" over real SSH, so the code path under test is the production path.
"""
import getpass
import os
import socket
import subprocess
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from nisse_core.execution.ssh import SSHHost
SSHD = "/usr/sbin/sshd"
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _keygen(path: Path) -> None:
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(path)], check=True
)
@contextmanager
def local_sshd(root: Path) -> Iterator[SSHHost]:
"""An SSHHost for a private sshd on 127.0.0.1, torn down afterwards."""
root.mkdir(parents=True, exist_ok=True)
host_key, client_key = root / "host_key", root / "client_key"
_keygen(host_key)
_keygen(client_key)
authorized = root / "authorized_keys"
authorized.write_text(client_key.with_suffix(".pub").read_text())
authorized.chmod(0o600)
port = _free_port()
config = root / "sshd_config"
config.write_text(
"\n".join(
[
f"Port {port}",
"ListenAddress 127.0.0.1",
f"HostKey {host_key}",
f"AuthorizedKeysFile {authorized}",
f"PidFile {root / 'sshd.pid'}",
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"PubkeyAuthentication yes",
"PermitRootLogin prohibit-password",
"StrictModes no",
"UsePAM no",
"MaxSessions 50",
"MaxStartups 50",
# Stands in for a provisioned build VM: sessions see the test
# runner's PATH, so its Python and tools are "installed".
f"SetEnv PATH={os.environ.get('PATH', '/usr/bin:/bin')}",
"",
]
)
)
process = subprocess.Popen(
[SSHD, "-D", "-e", "-f", str(config)],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
user = getpass.getuser()
host = SSHHost(
"127.0.0.1", key=client_key, state_dir=root / "client", user=user, port=port
)
try:
deadline = time.monotonic() + 10
while True:
if process.poll() is not None:
raise RuntimeError(
"sshd exited: " + (process.stderr.read() or b"").decode()
if process.stderr
else "sshd exited"
)
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
break
except OSError:
if time.monotonic() > deadline:
raise RuntimeError("sshd did not start") from None
time.sleep(0.05)
yield host
finally:
host.close()
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()