Files
nisse/nisse-core/src/nisse_core/execution.py
T
dongarandClaude Opus 5.5 154a0c3fa8
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m42s
CI / e2e (pull_request) Successful in 4m0s
M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour,
with its tests: canonical, render, render_anthropic, llm, catalog, probe,
context, views, state, reconcile, worker, cancel, dump, transport, matrix,
execution, testing, vault, memory, oplog, envfile and gitea (whole).

- nisse_core.state.StateStore holds the tables every agent has; an
  agent's subclass adds its tables (version, migrations), roles and
  views. nisse_dev.state.StateStore adds the build VM blocks (v5).
- views.project takes what each role is shown, rather than a fixed table.
- Vault takes the agent's name (agent=), for provenance.
- A test that nisse_core never imports an agent.
- The loop, config, git, delegation, expert, tools and evals stay in
  nisse-dev until nisse-ops has a loop to share.

Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on
the agent host and Terraform; Architecture Part L names the package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 18:37:01 +00:00

233 lines
7.9 KiB
Python

"""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,
)