Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).
New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""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
|