"""SSH adapter: broker-managed ssh authentication. Two credential modes: * agent.ssh_key_path set -> uses that existing local private key file (path metadata only -- key bytes never move through the broker) * otherwise -> password auth via SSH_ASKPASS, where the password is a 0600 file under the runtime tmpfs that the generated askpass script cats at prompt time. Never on argv. The remote command is passed as a single argv element to ssh -- no local shell interpolation is possible. """ from __future__ import annotations import os import re import shutil from ..config import state_dir from ..errors import invalid_argument, unsafe_tool_integration from ..policy import require_host_allowed from ..items import item_secrets from .common import (clean_env, run_subprocess, secret_dir, write_askpass, write_secret_file) def _known_hosts(): """Broker-managed known_hosts (TOFU trust stays in the broker's own state dir instead of silently writing to the user's ~/.ssh).""" d = state_dir() / "ssh" d.mkdir(mode=0o700, parents=True, exist_ok=True) p = d / "known_hosts" if not p.exists(): p.touch(mode=0o600) return p _CMD_MAX = 2000 _DANGEROUS = re.compile(r"[;&|`$<>]|^\s*(sudo\s+)?(rm|dd|mkfs|shutdown|reboot|poweroff)\b") def validate(item, policy, params, config): host = params.get("host") or "" command = params.get("command") or "" if not command or len(command) > _CMD_MAX: raise invalid_argument("missing or oversized command") if _DANGEROUS.search(command): raise invalid_argument("command rejected by safety policy") require_host_allowed(policy, host) def run(item, policy, params, config): host = params.get("host") or "" command = params.get("command") or "" port = int(params.get("port") or 22) if not command or len(command) > _CMD_MAX: raise invalid_argument("missing or oversized command") if _DANGEROUS.search(command): raise invalid_argument("command rejected by safety policy") require_host_allowed(policy, host) ssh = shutil.which("ssh") or "/usr/bin/ssh" user = policy.ssh_user or item.get("username") or "root" target = f"{user}@{host}" argv = [ ssh, "-o", "BatchMode=no", "-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={_known_hosts()}", "-o", "NumberOfPasswordPrompts=1", "-o", "IdentitiesOnly=yes", "-p", str(port), target, command, ] secrets = item_secrets(item) key_path = policy.ssh_key_path if key_path: if not os.path.isfile(key_path): raise unsafe_tool_integration("ssh: agent.ssh_key_path does not exist") argv = argv[:-2] + ["-i", key_path] + argv[-2:] result = run_subprocess(argv, env=clean_env(), timeout=30, secrets=secrets) else: password = item.get("password") if not password: raise unsafe_tool_integration("ssh: no key path and no password on item") with secret_dir() as d: pwfile = d / "pw" write_secret_file(pwfile, password) askpass = write_askpass(d, None, pwfile) env = clean_env({ "SSH_ASKPASS": str(askpass), "SSH_ASKPASS_REQUIRE": "force", "DISPLAY": "broker:0", }) result = run_subprocess(argv, env=env, timeout=30, secrets=secrets) result["host"] = host result["auth"] = "key" if key_path else "password-askpass" return result