diff --git a/container/skills/digging/SKILL.md b/container/skills/digging/SKILL.md new file mode 100644 index 00000000..d88c59c9 --- /dev/null +++ b/container/skills/digging/SKILL.md @@ -0,0 +1,97 @@ +--- +name: digging +description: Use owner-approved SSH access to inspect or set up a remote machine, bootstrap a Woltspace colony, and leave a durable local handoff. Use when a human asks a wolt to SSH into, dig into, configure, or prepare another machine. Do not use for Wire pairing or cross-colony IWCL. +--- + +# Dig into an owned machine + +Digging means temporarily using the owner's existing SSH access. The wolt stays +resident in its home colony: connect, do the authorized work, leave a local +handoff for future wolts on that machine, disconnect, and report home. + +Prefer `woltspace dig` over invoking `ssh` directly whenever the digging command +is available. The wrapper makes the intended wolt and destination visible, +rechecks the resolved target, preserves the handoff convention, and records a +small non-secret audit trail. It does not create a security boundary against a +wolt running as the same Unix user. + +## Find or create the approval + +Run `woltspace dig list --json`. Use an existing grant only when its wolt and +destination match the human's request. + +If none matches and the named destination is an unambiguous existing SSH alias, +set up the dig grant yourself rather than asking the human to remember CLI +syntax. Choose a short lowercase grant name and run: + +```sh +woltspace dig grant newbox newbox --wolt "$WOLTSPACE_WOLT_NAME" +``` + +Creating the grant only records a pointer to existing SSH access; it copies no +key, certificate, agent credential, or token. If the destination, wolt, or +intended work is ambiguous, ask before granting. Never guess a hostname or +browse unrelated SSH destinations looking for somewhere to connect. + +The grant records Woltspace consent; the SSH user's actual permissions remain +the authority. Do not create accounts, keys, tunnels, or broader server access +unless the human separately asks for that work. + +## Connect safely + +Immediately before the first connection in a task, show the human the grant +name and resolved `user@host:port`, briefly state the intended work, and ask for +confirmation. A stored grant is not standing permission to connect. + +Skip that confirmation only when the human's current instruction explicitly +says that permission is not required, says to proceed without asking, or gives +equally clear authorization to connect immediately. Do not infer a permanent +waiver from an earlier task or from the mere existence of SSH access. Once the +human confirms a task, do not repeatedly ask for every bounded command needed +to complete that same task unless the target or scope changes. + +Start with a small read-only probe appropriate to the request, for example: + +```sh +woltspace dig connect newbox -- hostname +woltspace dig connect newbox -- whoami +woltspace dig connect newbox -- command -v woltspace +``` + +Use `woltspace dig connect NAME -- COMMAND...` for bounded commands and +`woltspace dig connect NAME` only when an interactive shell is genuinely useful. +The command rechecks the approved SSH host, user, and port and requires strict +host-key verification. + +Never disable `StrictHostKeyChecking`, accept a new host key on the human's +behalf, copy a private SSH key, or send credentials through chat, Wire, command +arguments, or handoff files. If SSH authentication or host verification fails, +report the exact non-secret failure and let the human repair their normal SSH +configuration. + +## Bootstrap and hand off + +Make changes only within the task the human authorized. Preserve existing data +and inspect before overwriting configuration. + +The grant's `bootstrap_dir` is relative to the remote SSH user's home and +defaults to `.woltspace/bootstrap`. Create it when needed and leave concise, +human-readable artifacts for the future colony, such as: + +- what was installed or configured; +- important paths and decisions; +- checks run and their results; +- incomplete work, open questions, and safe next steps. + +Do not write into another wolt's private memory. Do not copy the visiting +wolt's identity, memories, sessions, credentials, or local configuration to the +remote machine. A colony seed may be installed there only when the human has +selected and authorized that seed; digging permission alone does not select one. + +## Return and report + +Disconnect when the requested work is complete or progress is blocked. Report +the destination, changes, verification, handoff path, and anything left undone. +Do not claim that `woltspace dig revoke NAME` removes real SSH access: it removes +only Woltspace's local grant. The owner must separately remove SSH keys, +accounts, or server authorization when underlying access should end. diff --git a/docs/digging-v0.md b/docs/digging-v0.md new file mode 100644 index 00000000..9de697c3 --- /dev/null +++ b/docs/digging-v0.md @@ -0,0 +1,169 @@ +# Wolt digging: product model and experimental roadmap + +Digging is a Woltspace feature that lets a wolt request a temporary guest work +session in another colony. Woltspace Wire is only the authenticated, encrypted +transport. Pairing colonies does not grant remote execution. + +## Beginner model + +- **Home** is the one colony where the wolt exists and keeps its identity, + memory, configuration, and long-lived session history. +- **Wire** lets paired colonies identify and call one another. It carries dig + requests, grants, callbacks, and results, but grants no execution authority. +- **Dig** is an explicitly approved temporary visit into a destination. SSH may + carry that visit, but SSH is an implementation detail rather than the product + concept. +- **IWCL** lets the temporary visiting session explain and coordinate with the + destination's resident wolts while it is there. + +Digging is not teleportation, installation, migration, or cloning. A visiting +wolt does not become a resident of the destination colony. + +## V0 user story + +A human lets a wolt dig into a machine they control. The wolt connects through +ordinary SSH, installs or configures what the machine needs, and leaves durable +local handoff files for the new colony's wolts to find when they wake up. It +disconnects and reports home what it changed and verified. + +The handoff may contain a bootstrap note, setup manifest, decisions, next steps, +and verification results. It belongs in an intentionally designated shared +bootstrap location, never another wolt's private memory. + +In short: **connect -> set up -> leave a local handoff -> return**. + +## Runnable v0: owner-provisioned SSH + +The owner configures ordinary SSH access using their existing OS user, +`~/.ssh/config`, agent, and keys. Woltspace records which wolt may dig to the +exact SSH host/user (and optionally a working directory), invokes normal SSH, +and gives the wolt the same remote shell and Woltspace CLI access that owner has +already authorized. The wolt can inspect or set up the machine, disconnect, and +report what changed. + +V0 does not create a guest Unix account, transport private SSH keys over Wire, +require a remote Woltspace agent, or provide IWCL across the colony boundary. +The SSH user's existing authority is the real authority; the Woltspace record +is an understandable local consent and audit boundary, not a sandbox. + +V0 must still pin or verify the SSH host key, bind consent to the exact host and +user, handle remote PATH and TTY behavior, distinguish home-wolt context from +remote resident files, report interrupted setup honestly, and provide a clear +way to remove both the Woltspace grant and underlying SSH access. + +### CLI + +```console +woltspace dig grant next-colony my-ssh-alias --wolt n00b +woltspace dig list +woltspace dig connect next-colony +woltspace dig connect next-colony -- woltspace status --json +woltspace dig revoke next-colony +``` + +`grant` resolves `my-ssh-alias` through the owner's existing OpenSSH config and +records the resulting host, user, and port. Every connection resolves it again +and refuses a changed tuple. SSH runs with `StrictHostKeyChecking=yes`, so an +unknown or changed server key fails instead of prompting the wolt to trust it. +The destination alias is passed as an argument, never through a local shell. + +The default remote handoff location is `.woltspace/bootstrap`, relative to the +remote SSH user's home. The command prints this location for the visiting wolt; +v0 intentionally leaves the contents human-readable rather than imposing a +protocol. A useful handoff includes what was installed, paths changed, checks +run, open questions, and next steps. Existing files should be preserved unless +the setup task explicitly authorizes replacing them. + +`revoke` removes Woltspace's local consent record. It cannot revoke the Unix +account, SSH key, agent, or server-side authorization; the owner must remove +those separately when access itself should end. + +### Disposable installed-wheel proof + +Reviewers with Docker can run the complete loopback-only SSH proof: + +```console +uv run python test/e2e/dig_ssh_container/run.py +``` + +It builds the candidate wheel, installs it on both client and disposable SSH +target, publishes the target only on a random `127.0.0.1` port, uses isolated +temporary keys/config/known-hosts/state, pins the generated host key, and proves +grant, installed CLI connection, a separate Unix home, colony bootstrap files, +handoff persistence after reconnect, audit, revoke, refusal after revoke, and +cleanup. It never uses the host's normal SSH configuration or Woltspace state. + +## Later: paired-colony visits + +Wire could later carry the request, destination approval, callback, and result. +That authorization layer is deliberately not part of this SSH v0. + +Cross-tunnel IWCL, a visiting-wolt identity bridge, callbacks, and ongoing +collaboration are explicitly out of v0. Real digging experience should tell us +whether they are needed and what identity and consent model they require. + +### Candidate v1 transport: Cloudflare + +For machine-to-machine digging, a destination-owned `cloudflared` connector can +reach Cloudflare over outbound-only connections, avoiding a public origin IP or +inbound router port. The durable tunnel is transport; per-dig authorization is +the ephemeral part. + +Two increments are possible: + +1. Reuse owner-managed SSH keys through a private Cloudflare Tunnel/WARP route. +2. Use Cloudflare Access for Infrastructure for short-lived SSH certificates, + exact user/port policy, and access or command auditing. + +The destination owns its tunnel token and never sends it to the visiting wolt. +Direct SSH exposure must still be blocked at the origin if Cloudflare-only +access is intended. The legacy Cloudflare short-lived-certificate application +flow is not a new-deployment target; evaluate Access for Infrastructure instead. + +This is unnecessary for two local users on one Mac. A future non-network +local-user transport would be a smaller solution for that case. + +## Possible V1 human experience + +1. Alice asks to dig to Bobeaver Colony with a short task description and a + destination-relative disposable workspace. +2. Wire delivers the signed and encrypted request from Alice's pinned colony + identity. +3. Bobeaver's human sees the source colony, source wolt, task, target, and short + expiry. They explicitly allow or reject this one dig. +4. Allow creates a random, short-lived, one-use grant bound to the verified + source colony and exact request. The grant returns over Wire. +5. Alice redeems it over the same pinned Wire relationship. Only then may + Woltspace create a constrained guest session. +6. Completion, expiry, or revocation closes the dig. Results and an inert audit + summary may return over Wire. + +The same lifecycle may begin without Wire: an owner can manually grant a wolt a +temporary visit to a server they control, with SSH or another Woltspace adapter +providing transport. Wire is the preferred paired-colony convenience path, not +a prerequisite for the general digging concept. + +## Security invariants + +- A Wire peer is a messenger, not a local authority. +- Pairing never implies permission to dig. +- Approval is local, explicit, exact-request, short-lived, and one-use. +- The request body cannot choose its authenticated source identity. +- Targets are destination-relative and later resolve only inside a newly + created disposable workspace. +- No host path, standing account, SSH private key, relay read capability, or + permanent shell credential crosses colonies. +- The home colony remains authoritative for the visitor's identity and memory. + The destination stores only bounded visit/audit records and never materializes + a second resident wolt. +- The destination can revoke before or during a dig. Active-session termination + is a required integration gate, not yet implemented by the kernel. +- The guest session receives a purpose-built policy and cannot inherit the + destination wolt's normal Auto grant. +- Logs and status omit bearer capabilities and message bodies by default. + +## Current implementation + +SSH v0 is the only implemented path. Wire authorization, cross-tunnel IWCL, +local-user switching, Cloudflare transport, and short-lived infrastructure +credentials remain design notes, not shipped capability. diff --git a/pyproject.toml b/pyproject.toml index c732ca90..87c7703c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ packages = ["src/woltspace"] "docs/updates.md" = "woltspace/_bundle/docs/updates.md" "docs/shared-skills.md" = "woltspace/_bundle/docs/shared-skills.md" "docs/colony-seeds.md" = "woltspace/_bundle/docs/colony-seeds.md" +"docs/digging-v0.md" = "woltspace/_bundle/docs/digging-v0.md" [tool.hatch.build.targets.sdist] include = [ @@ -71,5 +72,6 @@ include = [ "/docs/updates.md", "/docs/shared-skills.md", "/docs/colony-seeds.md", + "/docs/digging-v0.md", "/LICENSE", ] diff --git a/src/woltspace/cli.py b/src/woltspace/cli.py index 59c23221..bcd6db9e 100644 --- a/src/woltspace/cli.py +++ b/src/woltspace/cli.py @@ -8,7 +8,7 @@ import sys from . import __version__, lore -from .envvars import warn_legacy_once +from .envvars import get_env, warn_legacy_once from .layout import RuntimeLayout @@ -585,6 +585,103 @@ def _seed(args) -> int: return 1 +def _dig(args) -> int: + args.dig_parser.print_help() + return 1 + + +def _dig_store(): + from .dig import DigStore + + return DigStore(RuntimeLayout.from_env().state_root) + + +def _dig_grant(args) -> int: + from .dig import DigError, resolve_ssh + + layout = RuntimeLayout.from_env() + if not (layout.wolts_dir / args.wolt).is_dir(): + lore.failure(f"dig grant failed: unknown wolt: {args.wolt}") + return 1 + try: + resolved = resolve_ssh(args.destination) + grant = _dig_store().grant( + name=args.name, + wolt=args.wolt, + destination=args.destination, + resolved=resolved, + bootstrap_dir=args.bootstrap_dir, + ) + except DigError as exc: + lore.failure(f"dig grant failed: {exc}") + return 1 + if args.json: + print(json.dumps({"ok": True, "grant": grant}, indent=2)) + else: + lore.headline(lore.TRACKS, f"dig approved: {grant['name']}") + lore.labelled("wolt", grant["wolt"]) + lore.labelled("ssh", grant["destination"]) + lore.labelled("resolved", f"{resolved.user}@{resolved.hostname}:{resolved.port}") + lore.labelled("handoff", grant["bootstrap_dir"]) + lore.subtitle("this records consent; the SSH user's real permissions still apply") + return 0 + + +def _dig_list(args) -> int: + grants = sorted(_dig_store().list(), key=lambda item: item["name"]) + if args.json: + print(json.dumps({"grants": grants}, indent=2)) + elif not grants: + lore.headline(lore.TRACKS, "no approved digs") + else: + lore.headline(lore.TRACKS, f"approved digs: {len(grants)}") + for grant in grants: + lore.labelled(grant["name"], f"{grant['wolt']} -> {grant['destination']}") + return 0 + + +def _dig_revoke(args) -> int: + from .dig import DigError + + try: + revoked = _dig_store().revoke(args.name) + except DigError as exc: + lore.failure(f"dig revoke failed: {exc}") + return 1 + if args.json: + print(json.dumps({"ok": True, "revoked": revoked, "name": args.name}, indent=2)) + else: + lore.headline(lore.MOON, f"dig {'revoked' if revoked else 'was not approved'}: {args.name}") + lore.subtitle("remove the SSH key/config separately if access itself must end") + return 0 + + +def _dig_connect(args) -> int: + from .dig import DigError, connect + + try: + grant = _dig_store().get(args.name) + if not args.json: + lore.headline(lore.TRACKS, f"digging: {args.name}") + lore.labelled("wolt", grant["wolt"]) + lore.labelled("handoff", grant["bootstrap_dir"]) + remote_command = list(args.remote_command) + if remote_command[:1] == ["--"]: + remote_command = remote_command[1:] + return connect( + _dig_store(), + args.name, + command=remote_command, + actor_wolt=get_env("WOLTSPACE_WOLT_NAME", ""), + ) + except DigError as exc: + if args.json: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + else: + lore.failure(f"dig failed: {exc}") + return 1 + + def _seed_create(args) -> int: from .seed import SeedError, create_seed @@ -813,6 +910,33 @@ def build_parser() -> argparse.ArgumentParser: seed_install.add_argument("--json", action="store_true") seed_install.set_defaults(func=_seed_install) + dig = sub.add_parser("dig", help="let a wolt visit an owner-approved SSH host") + dig.set_defaults(func=_dig, dig_parser=dig) + dig_sub = dig.add_subparsers(dest="dig_command") + + dig_grant = dig_sub.add_parser("grant", help="approve an exact SSH destination") + dig_grant.add_argument("name") + dig_grant.add_argument("destination", help="SSH host or alias from ~/.ssh/config") + dig_grant.add_argument("--wolt", required=True) + dig_grant.add_argument("--bootstrap-dir", default=".woltspace/bootstrap") + dig_grant.add_argument("--json", action="store_true") + dig_grant.set_defaults(func=_dig_grant) + + dig_list = dig_sub.add_parser("list", help="show approved SSH destinations") + dig_list.add_argument("--json", action="store_true") + dig_list.set_defaults(func=_dig_list) + + dig_revoke = dig_sub.add_parser("revoke", help="remove a Woltspace dig approval") + dig_revoke.add_argument("name") + dig_revoke.add_argument("--json", action="store_true") + dig_revoke.set_defaults(func=_dig_revoke) + + dig_connect = dig_sub.add_parser("connect", help="open the approved SSH destination") + dig_connect.add_argument("name") + dig_connect.add_argument("remote_command", nargs=argparse.REMAINDER) + dig_connect.add_argument("--json", action="store_true") + dig_connect.set_defaults(func=_dig_connect) + tui = sub.add_parser("tui", help="open the terminal UI") tui.add_argument("--dry-run", action="store_true", help="show resolution without launching") tui.add_argument("--json", action="store_true", help=argparse.SUPPRESS) diff --git a/src/woltspace/dig.py b/src/woltspace/dig.py new file mode 100644 index 00000000..92a0434f --- /dev/null +++ b/src/woltspace/dig.py @@ -0,0 +1,251 @@ +"""Owner-approved SSH destinations for Woltspace digging v0.""" + +from __future__ import annotations + +import fcntl +import json +import os +import re +import shlex +import subprocess +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + + +STORE_VERSION = "woltspace.digs/v0" +DEFAULT_BOOTSTRAP_DIR = ".woltspace/bootstrap" +_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +_DESTINATION_RE = re.compile(r"^[A-Za-z0-9_.@:\[\]-]+$") +_REMOTE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") + + +class DigError(ValueError): + pass + + +@dataclass(frozen=True) +class ResolvedSSH: + hostname: str + user: str + port: int + + def to_record(self) -> dict: + return {"hostname": self.hostname, "user": self.user, "port": self.port} + + +def _validate_name(name: str) -> str: + if not _NAME_RE.fullmatch(name): + raise DigError("dig name must use lowercase letters, numbers, '_' or '-'") + return name + + +def _validate_destination(destination: str) -> str: + if not destination or destination.startswith("-") or not _DESTINATION_RE.fullmatch(destination): + raise DigError("SSH destination must be one host/alias without whitespace or options") + return destination + + +def _validate_bootstrap_dir(value: str) -> str: + path = Path(value) + if ( + not value + or not _REMOTE_PATH_RE.fullmatch(value) + or path.is_absolute() + or ".." in path.parts + ): + raise DigError("bootstrap directory must be relative to the remote user's home") + return value + + +def resolve_ssh( + destination: str, + *, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> ResolvedSSH: + destination = _validate_destination(destination) + ssh_config = os.environ.get("WOLTSPACE_DIG_SSH_CONFIG", "").strip() + config_args = ["-F", ssh_config] if ssh_config else [] + result = runner( + ["ssh", *config_args, "-G", "--", destination], + capture_output=True, + text=True, + check=False, + ) + if result.returncode: + detail = (result.stderr or "ssh configuration could not be resolved").strip() + raise DigError(detail) + values: dict[str, str] = {} + for line in result.stdout.splitlines(): + key, separator, value = line.partition(" ") + if separator and key in {"hostname", "user", "port"} and key not in values: + values[key] = value.strip() + if not values.get("hostname") or not values.get("user"): + raise DigError("ssh -G did not return a hostname and user") + try: + port = int(values.get("port", "22")) + except ValueError as exc: + raise DigError("ssh -G returned an invalid port") from exc + if not 1 <= port <= 65535: + raise DigError("ssh -G returned an invalid port") + return ResolvedSSH(values["hostname"], values["user"], port) + + +class DigStore: + def __init__(self, state_root: str | Path): + self.root = Path(state_root) / "digs" + self.path = self.root / "grants.json" + self.lock_path = self.root / "grants.lock" + + @contextmanager + def _locked(self): + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(self.root, 0o700) + fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) + + def _read(self) -> dict: + if not self.path.exists(): + return {"version": STORE_VERSION, "grants": []} + try: + payload = json.loads(self.path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise DigError("dig grant store is unreadable") from exc + if payload.get("version") != STORE_VERSION or not isinstance(payload.get("grants"), list): + raise DigError("dig grant store has an unsupported shape") + return payload + + def _write(self, payload: dict) -> None: + temp = self.path.with_suffix(".tmp") + data = (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode() + fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, self.path) + finally: + try: + temp.unlink() + except FileNotFoundError: + pass + + def grant( + self, + *, + name: str, + wolt: str, + destination: str, + resolved: ResolvedSSH, + bootstrap_dir: str = DEFAULT_BOOTSTRAP_DIR, + now: int | None = None, + ) -> dict: + name = _validate_name(name) + destination = _validate_destination(destination) + bootstrap_dir = _validate_bootstrap_dir(bootstrap_dir) + now = int(time.time()) if now is None else int(now) + record = { + "name": name, + "wolt": wolt, + "destination": destination, + "resolved": resolved.to_record(), + "bootstrap_dir": bootstrap_dir, + "created_at": now, + "connect_count": 0, + "last_connected_at": None, + "last_exit_code": None, + } + with self._locked(): + payload = self._read() + if any(item.get("name") == name for item in payload["grants"]): + raise DigError(f"dig already exists: {name}") + payload["grants"].append(record) + self._write(payload) + return dict(record) + + def get(self, name: str) -> dict: + _validate_name(name) + with self._locked(): + for record in self._read()["grants"]: + if record.get("name") == name: + return dict(record) + raise DigError(f"unknown dig: {name}") + + def list(self) -> list[dict]: + with self._locked(): + return [dict(item) for item in self._read()["grants"]] + + def revoke(self, name: str) -> bool: + _validate_name(name) + with self._locked(): + payload = self._read() + kept = [item for item in payload["grants"] if item.get("name") != name] + if len(kept) == len(payload["grants"]): + return False + payload["grants"] = kept + self._write(payload) + return True + + def record_connection(self, name: str, *, exit_code: int, now: int | None = None) -> None: + now = int(time.time()) if now is None else int(now) + with self._locked(): + payload = self._read() + for record in payload["grants"]: + if record.get("name") == name: + record["connect_count"] = int(record.get("connect_count", 0)) + 1 + record["last_connected_at"] = now + record["last_exit_code"] = int(exit_code) + self._write(payload) + return + raise DigError(f"unknown dig: {name}") + + +def connect( + store: DigStore, + name: str, + *, + command: Sequence[str] = (), + actor_wolt: str = "", + resolver: Callable[[str], ResolvedSSH] = resolve_ssh, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> int: + grant = store.get(name) + if actor_wolt and actor_wolt != grant["wolt"]: + raise DigError(f"dig belongs to wolt {grant['wolt']}, not {actor_wolt}") + current = resolver(grant["destination"]) + if current.to_record() != grant["resolved"]: + raise DigError("SSH destination no longer resolves to the approved host, user, and port") + argv = [ + "ssh", + *( + ["-F", os.environ["WOLTSPACE_DIG_SSH_CONFIG"]] + if os.environ.get("WOLTSPACE_DIG_SSH_CONFIG", "").strip() + else [] + ), + "-o", "StrictHostKeyChecking=yes", + "--", + grant["destination"], + ] + if command: + # OpenSSH concatenates every remaining local argv item into one remote + # shell command without preserving argument boundaries. Quote once + # here so spaces and metacharacters inside an intended argument remain + # data when the remote login shell parses it. + argv.append(shlex.join(command)) + try: + result = runner(argv, check=False) + except KeyboardInterrupt: + store.record_connection(name, exit_code=130) + raise + except OSError as exc: + store.record_connection(name, exit_code=126) + raise DigError(f"could not start ssh: {exc}") from exc + store.record_connection(name, exit_code=result.returncode) + return int(result.returncode) diff --git a/test/e2e/dig_ssh_container/Dockerfile b/test/e2e/dig_ssh_container/Dockerfile new file mode 100644 index 00000000..a5ecf6ec --- /dev/null +++ b/test/e2e/dig_ssh_container/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash colony \ + && install -d -m 0755 /run/sshd \ + && install -d -o colony -g colony -m 0700 /home/colony/.ssh + +COPY woltspace-*.whl /tmp/ +COPY authorized_keys /home/colony/.ssh/authorized_keys + +RUN python -m pip install --no-cache-dir /tmp/woltspace-*.whl \ + && rm /tmp/woltspace-*.whl \ + && chown colony:colony /home/colony/.ssh/authorized_keys \ + && chmod 0600 /home/colony/.ssh/authorized_keys \ + && printf '%s\n' \ + 'PasswordAuthentication no' \ + 'KbdInteractiveAuthentication no' \ + 'PermitRootLogin no' \ + 'AllowUsers colony' \ + >> /etc/ssh/sshd_config \ + && ssh-keygen -A + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/test/e2e/dig_ssh_container/run.py b/test/e2e/dig_ssh_container/run.py new file mode 100644 index 00000000..0d34ae80 --- /dev/null +++ b/test/e2e/dig_ssh_container/run.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Installed-wheel Dig proof against a disposable loopback-only SSH target.""" + +from __future__ import annotations + +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +DOCKERFILE = Path(__file__).with_name("Dockerfile") + + +def run(argv, *, env=None, check=True, capture=True, input_text=None): + return subprocess.run( + [str(item) for item in argv], + cwd=ROOT, + env=env, + check=check, + capture_output=capture, + text=True, + input=input_text, + ) + + +def wait_for_port(container: str) -> tuple[str, int]: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + result = run(["docker", "port", container, "22/tcp"], check=False) + value = result.stdout.strip() + if value: + host, port = value.rsplit(":", 1) + if host != "127.0.0.1": + raise AssertionError(f"SSH was not loopback-only: {value}") + return host, int(port) + time.sleep(0.2) + raise AssertionError("Docker never published the SSH port") + + +def scan_host_key(port: int) -> str: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + result = run( + ["ssh-keyscan", "-T", "2", "-p", str(port), "127.0.0.1"], + check=False, + ) + lines = [line for line in result.stdout.splitlines() if line and not line.startswith("#")] + if lines: + return "\n".join(lines) + "\n" + time.sleep(0.2) + raise AssertionError("disposable SSH server never offered a host key") + + +def assert_private(path: Path, mode: int) -> None: + actual = path.stat().st_mode & 0o777 + if actual != mode: + raise AssertionError(f"{path} mode is {actual:o}, expected {mode:o}") + + +def main() -> int: + for command in ("docker", "uv", "ssh", "ssh-keygen", "ssh-keyscan"): + if shutil.which(command) is None: + raise SystemExit(f"required command is missing: {command}") + + suffix = secrets.token_hex(5) + image = f"woltspace-dig-e2e:{suffix}" + container = f"woltspace-dig-e2e-{suffix}" + + with tempfile.TemporaryDirectory(prefix="woltspace-dig-e2e-") as raw_temp: + temp = Path(raw_temp) + context = temp / "context" + artifacts = temp / "artifacts" + client_home = temp / "client-home" + client_wolts = temp / "client-wolts" + venv = temp / "client-venv" + for directory in (context, artifacts, client_home, client_wolts / "n00b"): + directory.mkdir(parents=True) + + key = temp / "dig_key" + run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key]) + shutil.copyfile(f"{key}.pub", context / "authorized_keys") + + run(["uv", "build", "--wheel", "--out-dir", artifacts], capture=False) + wheel = next(artifacts.glob("*.whl")) + shutil.copyfile(wheel, context / wheel.name) + + try: + run(["docker", "build", "-f", DOCKERFILE, "-t", image, context], capture=False) + run([ + "docker", "run", "--rm", "-d", "--name", container, + "--publish", "127.0.0.1::22", image, + ]) + _, port = wait_for_port(container) + + known_hosts = temp / "known_hosts" + known_hosts.write_text(scan_host_key(port)) + known_hosts.chmod(0o600) + key.chmod(0o600) + + ssh_config = temp / "ssh_config" + ssh_config.write_text( + "Host dig-e2e\n" + " HostName 127.0.0.1\n" + " User colony\n" + f" Port {port}\n" + f" IdentityFile {key}\n" + " IdentitiesOnly yes\n" + " BatchMode yes\n" + f" UserKnownHostsFile {known_hosts}\n" + ) + ssh_config.chmod(0o600) + + run(["uv", "venv", "--python", "3.13", venv], capture=False) + run(["uv", "pip", "install", "--python", venv / "bin/python", wheel], capture=False) + cli = venv / "bin/woltspace" + env = { + **os.environ, + "HOME": str(client_home), + "WOLTSPACE_WOLTS_DIR": str(client_wolts), + "WOLTSPACE_WOLT_NAME": "n00b", + "WOLTSPACE_DIG_SSH_CONFIG": str(ssh_config), + } + + grant = run([ + cli, "dig", "grant", "throwaway", "dig-e2e", + "--wolt", "n00b", "--json", + ], env=env) + grant_payload = json.loads(grant.stdout) + resolved = grant_payload["grant"]["resolved"] + assert resolved == {"hostname": "127.0.0.1", "user": "colony", "port": port} + + whoami = run([ + cli, "dig", "connect", "--json", "throwaway", "--", "whoami", + ], env=env) + assert whoami.stdout.strip() == "colony" + + remote_script = ( + "from pathlib import Path; import json; " + "root=Path.home()/'.woltspace'; " + "w=root/'wolts'/'seedling'/'wolt'; w.mkdir(parents=True, exist_ok=True); " + "(w/'wolt.json').write_text(json.dumps({'name':'seedling','type':'raccoon'})+'\\n'); " + "b=root/'bootstrap'; b.mkdir(parents=True, exist_ok=True); " + "(b/'dig-handoff.json').write_text(json.dumps({" + "'version':'woltspace.dig-handoff/v0','visitor':'n00b'," + "'created_colony':'seedling','status':'ready'},sort_keys=True)+'\\n')" + ) + run([ + cli, "dig", "connect", "--json", "throwaway", "--", + "python", "-c", remote_script, + ], env=env) + + handoff = run([ + cli, "dig", "connect", "--json", "throwaway", "--", + "cat", ".woltspace/bootstrap/dig-handoff.json", + ], env=env) + handoff_payload = json.loads(handoff.stdout) + assert handoff_payload == { + "created_colony": "seedling", + "status": "ready", + "version": "woltspace.dig-handoff/v0", + "visitor": "n00b", + } + + remote_version = run([ + cli, "dig", "connect", "--json", "throwaway", "--", + "python", "-c", "import woltspace; print(woltspace.__version__)", + ], env=env) + assert remote_version.stdout.strip() == "0.5.6" + + listing = json.loads(run([cli, "dig", "list", "--json"], env=env).stdout) + audit = listing["grants"][0] + assert audit["connect_count"] == 4 + assert audit["last_exit_code"] == 0 + store = client_wolts / ".space" / "digs" / "grants.json" + assert_private(store.parent, 0o700) + assert_private(store, 0o600) + assert "PRIVATE KEY" not in store.read_text() + assert remote_script not in store.read_text() + + revoked = json.loads(run([ + cli, "dig", "revoke", "throwaway", "--json", + ], env=env).stdout) + assert revoked == {"ok": True, "revoked": True, "name": "throwaway"} + refused = run([ + cli, "dig", "connect", "--json", "throwaway", "--", "true", + ], env=env, check=False) + assert refused.returncode == 1 + assert json.loads(refused.stdout)["error"] == "unknown dig: throwaway" + + print(json.dumps({ + "ok": True, + "transport": f"127.0.0.1:{port}", + "remote_user": "colony", + "installed_woltspace": remote_version.stdout.strip(), + "handoff": handoff_payload, + "connections_audited": audit["connect_count"], + "revoked": True, + }, indent=2)) + finally: + run(["docker", "rm", "-f", container], check=False) + run(["docker", "image", "rm", "-f", image], check=False) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/test_dig_cli.py b/test/test_dig_cli.py new file mode 100644 index 00000000..a82c47e1 --- /dev/null +++ b/test/test_dig_cli.py @@ -0,0 +1,176 @@ +import json +import subprocess + +import pytest + +from woltspace.dig import DigError, DigStore, ResolvedSSH, connect, resolve_ssh + + +def resolved(host="server.example", user="colony", port=22): + return ResolvedSSH(host, user, port) + + +def grant(store): + return store.grant( + name="new-colony", + wolt="n00b", + destination="my-colony", + resolved=resolved(), + now=100, + ) + + +def test_resolve_ssh_uses_config_without_a_shell(): + calls = [] + + def runner(argv, **kwargs): + calls.append((argv, kwargs)) + return subprocess.CompletedProcess( + argv, 0, "hostname server.example\nuser colony\nport 2222\n", "" + ) + + assert resolve_ssh("my-colony", runner=runner) == resolved(port=2222) + assert calls[0][0] == ["ssh", "-G", "--", "my-colony"] + + +def test_isolated_ssh_config_can_be_selected_from_environment(monkeypatch): + calls = [] + monkeypatch.setenv("WOLTSPACE_DIG_SSH_CONFIG", "/tmp/isolated config") + + def runner(argv, **kwargs): + calls.append(argv) + return subprocess.CompletedProcess( + argv, 0, "hostname server.example\nuser colony\nport 22\n", "" + ) + + resolve_ssh("my-colony", runner=runner) + assert calls == [[ + "ssh", "-F", "/tmp/isolated config", "-G", "--", "my-colony" + ]] + + +@pytest.mark.parametrize("destination", ["-oProxyCommand=oops", "host name", "host;id", ""]) +def test_destination_cannot_inject_ssh_options(destination): + with pytest.raises(DigError): + resolve_ssh(destination, runner=lambda *a, **k: None) + + +def test_grant_store_is_private_and_revoke_is_honest(tmp_path): + store = DigStore(tmp_path / ".space") + record = grant(store) + assert record["bootstrap_dir"] == ".woltspace/bootstrap" + assert store.root.stat().st_mode & 0o777 == 0o700 + assert store.path.stat().st_mode & 0o777 == 0o600 + assert store.revoke("new-colony") is True + assert store.revoke("new-colony") is False + with pytest.raises(DigError, match="unknown dig"): + store.get("new-colony") + + +def test_connect_rechecks_resolution_uses_strict_host_key_and_audits(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + calls = [] + + def runner(argv, **kwargs): + calls.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 7) + + code = connect( + store, + "new-colony", + command=("woltspace", "status", "--json"), + resolver=lambda _: resolved(), + runner=runner, + ) + assert code == 7 + assert calls == [([ + "ssh", "-o", "StrictHostKeyChecking=yes", "--", "my-colony", + "woltspace status --json", + ], {"check": False})] + audited = store.get("new-colony") + assert audited["connect_count"] == 1 + assert audited["last_exit_code"] == 7 + + +def test_connect_refuses_changed_ssh_resolution_before_running(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + ran = False + + def runner(*args, **kwargs): + nonlocal ran + ran = True + + with pytest.raises(DigError, match="no longer resolves"): + connect(store, "new-colony", resolver=lambda _: resolved(host="evil.example"), runner=runner) + assert ran is False + assert store.get("new-colony")["connect_count"] == 0 + + +def test_session_wolt_cannot_use_another_wolts_dig(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + with pytest.raises(DigError, match="belongs to wolt n00b"): + connect( + store, + "new-colony", + actor_wolt="someone-else", + resolver=lambda _: resolved(), + runner=lambda *a, **k: None, + ) + + +def test_interrupted_connection_is_audited(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + + def interrupted(*args, **kwargs): + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + connect(store, "new-colony", resolver=lambda _: resolved(), runner=interrupted) + audit = store.get("new-colony") + assert audit["connect_count"] == 1 + assert audit["last_exit_code"] == 130 + + +def test_bootstrap_path_cannot_escape_remote_home(tmp_path): + store = DigStore(tmp_path / ".space") + with pytest.raises(DigError, match="relative"): + store.grant( + name="bad", + wolt="n00b", + destination="host", + resolved=resolved(), + bootstrap_dir="../../etc", + ) + + +def test_store_contains_no_credentials_or_command_bodies(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + payload = json.loads(store.path.read_text()) + text = json.dumps(payload) + assert "PRIVATE KEY" not in text + assert "remote_command" not in text + assert payload["version"] == "woltspace.digs/v0" + + +def test_remote_command_arguments_are_shell_quoted(tmp_path): + store = DigStore(tmp_path / ".space") + grant(store) + calls = [] + + def runner(argv, **kwargs): + calls.append(argv) + return subprocess.CompletedProcess(argv, 0) + + connect( + store, + "new-colony", + command=("printf", "%s", "hello; not a second command"), + resolver=lambda _: resolved(), + runner=runner, + ) + assert calls[0][-1] == "printf %s 'hello; not a second command'"