agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend

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.
This commit is contained in:
me committed 2026-10-14 13:27:33 -06:00
commit be87900794
35 files changed
+5003

No files matched your search

@@ -0,0 +1,58 @@
"""Adapter registry. Adapters receive (item, policy, params, config)
in-process, inject the secret through an approved channel, execute a
bounded operation, and return sanitized metadata only."""
from __future__ import annotations
from ..errors import denied
def _load():
from . import http, ssh, git, cli_profile, browser
return {
"http": http.run,
"ssh": ssh.run,
"git": git.run,
"cli-profile": cli_profile.run,
"browser": browser.run,
}
def _mods():
from . import http, ssh, git, cli_profile, browser
return {
"http": http,
"ssh": ssh,
"git": git,
"cli-profile": cli_profile,
"browser": browser,
}
class _Registry:
_impl = None
@classmethod
def _mod(cls, adapter):
if cls._impl is None:
cls._impl = _mods()
mod = cls._impl.get(adapter)
if mod is None:
raise denied(f"unknown adapter {adapter!r}")
return mod
@classmethod
def validate(cls, adapter, item, policy, params, config):
"""Pre-execution policy/argument validation -- runs BEFORE the
confirmation gate so target errors surface first."""
mod = cls._mod(adapter)
fn = getattr(mod, "validate", None)
if fn:
fn(item, policy, params, config)
@classmethod
def run(cls, adapter, item, policy, params, config):
return cls._mod(adapter).run(item, policy, params, config)
registry = _Registry()
+115
View File
@@ -0,0 +1,115 @@
"""Browser adapter.
Preferred path (native provider): the agent runs
agent-browser auth login <name> --credential-provider broker \\
--item <agent.ref>
agent-browser then invokes the broker's ``credential.read`` plugin and
the secret flows plugin-stdout -> agent-browser, never through the
agent. That plugin lives in plugin.py / entry point
``agent-broker-plugin``.
Broker-side operations implemented here:
browser_login executes ``agent-browser auth login`` with the
plugin flags (no secrets on argv -- resolution
happens inside agent-browser via the plugin)
browser_fill_credential same mechanism on an existing page
(--no-navigate + --url origin constraint +
custom selectors from agent.* fields)
Granular single-field fill via ``agent-browser fill`` is NOT offered:
that command takes text on argv, which would expose the secret in
/proc/<pid>/cmdline. If a site cannot be handled by the auth-login
mechanism, the adapter raises UNSAFE_TOOL_INTEGRATION instead of
downgrading to argv leaks.
"""
from __future__ import annotations
import os
import re
import shutil
from ..config import socket_path
from ..errors import invalid_argument, unsafe_tool_integration
from ..policy import require_url_allowed
from ..items import item_secrets
from .common import clean_env, run_subprocess
_SEL = re.compile(r"^[#.\[\]\w:='\"() >+~*-]{1,300}$")
def _sel(v):
if v and not _SEL.match(v):
raise invalid_argument("bad selector")
return v
def _agent_browser(config) -> str:
exe = shutil.which("agent-browser")
if not exe:
raise unsafe_tool_integration(
"agent-browser not installed; browser adapter unavailable")
return exe
def validate(item, policy, params, config):
url = params.get("url") or (policy.uris[0] if policy.uris else None)
if not url:
raise invalid_argument("browser op requires a url")
require_url_allowed(policy, url)
def run(item, policy, params, config):
op = params.get("op") or params.get("operation") or "browser_login"
url = params.get("url") or (policy.uris[0] if policy.uris else None)
if not url:
raise invalid_argument("browser op requires a url")
require_url_allowed(policy, url)
exe = _agent_browser(config)
argv = [exe, "auth", "login", policy.ref,
"--credential-provider", "broker",
"--item", policy.ref,
"--url", url]
us = _sel(params.get("username_selector") or policy.username_selector)
ps = _sel(params.get("password_selector") or policy.password_selector)
ss = _sel(params.get("submit_selector") or policy.submit_selector)
if us:
argv += ["--username-selector", us]
if ps:
argv += ["--password-selector", ps]
if ss:
argv += ["--submit-selector", ss]
if op == "browser_fill_credential":
argv.append("--no-navigate")
elif op != "browser_login":
raise invalid_argument(f"unsupported browser op {op!r}")
session = params.get("session")
if session:
if not re.match(r"^[\w.\-]{1,64}$", str(session)):
raise invalid_argument("bad session name")
argv += ["--session", str(session)]
plugin = shutil.which("agent-broker-plugin")
if not plugin:
raise unsafe_tool_integration(
"agent-broker-plugin not on PATH; browser adapter unavailable")
# child PATH must resolve the right node (agent-browser shebang) and
# the plugin binary; both live outside the base PATH on this host.
extra_paths = sorted({os.path.dirname(exe), os.path.dirname(plugin)})
env = clean_env({
"AGENT_BROKER_SOCKET": str(socket_path()),
"AGENT_BROWSER_CONFIG_DIR": os.environ.get(
"AGENT_BROWSER_CONFIG_DIR", os.path.expanduser("~/.agent-browser")),
# agent-browser session daemons live under $XDG_RUNTIME_DIR; without it
# the child cannot attach to an existing session browser.
"XDG_RUNTIME_DIR": os.environ.get("XDG_RUNTIME_DIR", ""),
}, prepend_paths=extra_paths)
res = run_subprocess(argv, env=env, timeout=120, secrets=item_secrets(item))
res["op"] = op
res["via"] = "agent-browser credential.read plugin"
return res
@@ -0,0 +1,168 @@
"""CLI-profile adapter: bounded subprocess execution of pre-declared
tools only. There is intentionally NO generic "run with secret" -- every
profile is a declarative allowlist:
{
"id": "example-cli",
"executable": "example-cli",
"injection": {"method": "env", "name": "EXAMPLE_TOKEN"},
"allowed_subcommands": ["list", "status"],
"arg_regex": "^[\\w .\\-/:]+$",
"forbidden_regex": "[;&|`$<>(){}\\[\\]]",
"target_hosts": ["api.example.com"],
"timeout": 30,
"max_output_bytes": 65536
}
Injection methods (in order of preference):
stdin -- secret written to child stdin (no argv, no env, no file)
file -- 0600 tmpfs file rendered from file_template ({SECRET}
placeholder); the child gets its PATH via env_name and/or
profile args_template entries containing {SECRET_FILE}
env -- secret exported ONLY into the child environment
none -- no secret reaches the child (metadata-only calls)
Profile args_template entries are broker-supplied args (they may embed
{SECRET_FILE} but NEVER the secret itself), so tools like curl can pick
credentials up from config files without argv exposure. Agent-supplied
args are validated separately.
argv is never an injection channel.
"""
from __future__ import annotations
import json
import re
import shutil
import urllib.parse
from pathlib import Path
from ..config import profiles_dir
from ..errors import denied, invalid_argument
from ..items import item_secrets
from .common import clean_env, run_subprocess, secret_dir, write_secret_file
_DEFAULT_ARG_RE = r"^[\w ./:@\-+=%]+$"
_DEFAULT_FORBIDDEN_RE = r"[;&|`$<>(){}\[\]\n!#]|(^|/)\.\.(/|$)"
_MAX_ARGS = 64
def load_profile(profile_id: str) -> dict:
if not profile_id or not re.match(r"^[\w.\-]+$", profile_id):
raise invalid_argument("bad profile id")
path = profiles_dir() / f"{profile_id}.json"
if not path.is_file():
raise denied(f"unknown cli profile {profile_id!r}")
try:
profile = json.loads(path.read_text())
except Exception:
raise denied(f"invalid cli profile {profile_id!r}")
if profile.get("id") != profile_id:
raise denied(f"profile id mismatch in {profile_id!r}")
if profile.get("disabled"):
raise denied(f"cli profile {profile_id!r} is disabled")
return profile
def _validate_args(profile, args):
if not isinstance(args, list) or len(args) > _MAX_ARGS:
raise invalid_argument("bad args")
arg_re = re.compile(profile.get("arg_regex") or _DEFAULT_ARG_RE)
forbidden = profile.get("forbidden_regex")
for a in args:
if not isinstance(a, str) or not a or len(a) > 500:
raise invalid_argument("bad argument")
if not arg_re.match(a):
raise invalid_argument(f"argument {a!r} rejected by profile arg_regex")
if forbidden and re.search(forbidden, a):
raise invalid_argument(f"argument {a!r} rejected by profile forbidden_regex")
subs = profile.get("allowed_subcommands")
if subs:
positional = [a for a in args if not a.startswith("-")]
if not positional or positional[0] not in subs:
raise invalid_argument(
f"first positional must be one of {subs}")
targets = {h.lower() for h in (profile.get("target_hosts") or [])}
if targets:
for a in args:
if "://" in a:
host = (urllib.parse.urlparse(a).hostname or "").lower()
if host not in targets:
raise invalid_argument(
f"url target {host!r} not in profile target_hosts")
def validate(item, policy, params, config):
profile_id = params.get("profile")
profile = load_profile(profile_id)
if policy.cli_profile and policy.cli_profile != profile_id:
raise denied(f"credential {policy.ref!r} is bound to profile {policy.cli_profile!r}")
_validate_args(profile, params.get("args") or [])
def run(item, policy, params, config):
profile_id = params.get("profile")
profile = load_profile(profile_id)
if policy.cli_profile and policy.cli_profile != profile_id:
raise denied(f"credential {policy.ref!r} is bound to profile {policy.cli_profile!r}")
executable = profile.get("executable") or ""
if "/" in executable or ".." in executable:
raise denied("profile executable must be a bare name")
exe = shutil.which(executable)
if not exe:
raise denied(f"executable {executable!r} not found")
args = params.get("args") or []
_validate_args(profile, args)
injection = profile.get("injection") or {}
method = injection.get("method")
secret = item.get("password") or ""
secrets = item_secrets(item)
timeout = int(profile.get("timeout") or 30)
argv = [exe] + [str(a) for a in args]
input_text = None
if method == "stdin":
input_text = secret + "\n"
env = clean_env(profile.get("env") or {})
elif method == "file":
with secret_dir() as d:
sfile = d / "secret"
template = injection.get("file_template") or "{SECRET}"
if "{SECRET}" not in template:
raise denied("file_template must contain {SECRET}")
write_secret_file(sfile, template.replace("{SECRET}", secret))
env = clean_env(profile.get("env") or {})
env_name = injection.get("env_name")
if env_name:
env[env_name] = str(sfile)
extra = [a.replace("{SECRET_FILE}", str(sfile))
for a in (profile.get("args_template") or [])]
argv = [exe] + extra + argv[1:]
res = run_subprocess(argv, env=env, input_text=None,
timeout=timeout, secrets=secrets)
res["profile"] = profile_id
res["injection"] = "file"
return _cap(profile, res)
elif method == "env":
env = clean_env(profile.get("env") or {})
env[injection.get("name") or "CREDENTIAL"] = secret
else:
env = clean_env(profile.get("env") or {})
if method not in (None, "none"):
raise denied(f"unknown injection method {method!r}")
res = run_subprocess(argv, env=env, input_text=input_text,
timeout=timeout, secrets=secrets)
res["profile"] = profile_id
res["injection"] = method or "none"
return _cap(profile, res)
def _cap(profile, res):
cap = int(profile.get("max_output_bytes") or 65536)
res["stdout"] = res["stdout"][:cap]
res["stderr"] = res["stderr"][:cap]
return res
+117
View File
@@ -0,0 +1,117 @@
"""Shared helpers for adapters: bounded subprocesses and askpass scripts.
Secret-bearing helper files are created only under
$XDG_RUNTIME_DIR/agent-broker/tmp (tmpfs, 0700 dir, 0600
files) and deleted immediately after use. Secrets never appear on argv.
"""
from __future__ import annotations
import os
import secrets as _secrets
import subprocess
from contextlib import contextmanager
from pathlib import Path
from ..config import tmp_dir
from ..sanitize import sanitize_text, sanitize_bytes
def make_secret_dir() -> Path:
d = tmp_dir() / _secrets.token_hex(12)
d.mkdir(mode=0o700)
return d
def write_secret_file(path: Path, value: str):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(value)
def write_askpass(dirpath: Path, user_file: Path | None, pass_file: Path) -> Path:
"""askpass script reads values from sibling files -- the secret is
never inside the script itself or on any argv."""
script = dirpath / "askpass.sh"
lines = [
"#!/bin/sh",
"case \"$1\" in",
]
if user_file is not None:
lines += [
" *sername*|*ogin*) cat %s ;;",
f" *) cat {pass_file} ;;",
]
else:
lines.append(f" *) cat {pass_file} ;;")
lines.append("esac")
if user_file is not None:
lines[3] = lines[3] % user_file
script.write_text("\n".join(lines) + "\n")
os.chmod(script, 0o700)
return script
def run_subprocess(argv: list[str], *, env: dict | None = None, input_text: str | None = None,
timeout: int = 60, secrets: list[str] = ()) -> dict:
"""Run argv without a shell. Returns sanitized {exit_code, stdout,
stderr}. Never raises the child's output raw."""
try:
proc = subprocess.run(
argv,
env=env,
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
)
rc, raw_out, raw_err = proc.returncode, proc.stdout, proc.stderr
timed_out = False
except subprocess.TimeoutExpired as e:
rc, timed_out = 124, True
raw_out = (e.stdout or b"").decode("utf-8", "replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
raw_err = ((e.stderr or b"").decode("utf-8", "replace") if isinstance(e.stderr, bytes) else (e.stderr or "")) + \
f"\n[timeout after {timeout}s]"
out = sanitize_bytes(raw_out.encode(), [s.encode() for s in secrets if s])
err = sanitize_bytes(raw_err.encode(), [s.encode() for s in secrets if s])
result = {
"exit_code": rc,
"stdout": sanitize_text(out.decode("utf-8", "replace"), secrets),
"stderr": sanitize_text(err.decode("utf-8", "replace"), secrets),
}
if timed_out:
result["timed_out"] = True
return result
@contextmanager
def secret_dir():
d = make_secret_dir()
try:
yield d
finally:
for child in d.iterdir():
try:
child.unlink()
except OSError:
pass
try:
d.rmdir()
except OSError:
pass
def clean_env(extra: dict | None = None, prepend_paths: list[str] = ()) -> dict:
"""Minimal environment for secret-bearing children: no inherited
credential variables are added beyond what the adapter explicitly
injects. prepend_paths lists directories that must precede the base
PATH (e.g. the dir of a resolved executable whose shebang needs a
specific interpreter)."""
env = {
"PATH": ":".join([*prepend_paths, "/usr/local/bin", "/usr/bin", "/bin"]),
"HOME": os.environ.get("HOME", "/"),
"LANG": "C.UTF-8",
}
if extra:
env.update(extra)
return env
+135
View File
@@ -0,0 +1,135 @@
"""Git adapter: clone/fetch/push over HTTPS with askpass auth.
The token/password is supplied through a generated GIT_ASKPASS script
that cats 0600 runtime-tmpfs files -- it never appears on argv, never
inside the remote URL, and never in output (output is sanitized too).
Credentials embedded in URLs (https://user:pass@host) are rejected.
"""
from __future__ import annotations
import configparser
import os
import re
import shutil
import urllib.parse
from pathlib import Path
from ..errors import invalid_argument, origin_denied, unsafe_tool_integration
from ..policy import require_url_allowed
from ..items import item_secrets
from .common import (clean_env, run_subprocess, secret_dir, write_askpass,
write_secret_file)
_OPS = {"clone", "fetch", "push"}
_REFSPEC_OK = re.compile(r"^[\w\-./:+]+$")
def _validate_repo_url(item, policy, url):
if not url:
raise invalid_argument("missing repository url")
if url.startswith("git@") or url.startswith("ssh://"):
raise unsafe_tool_integration(
"git: ssh remotes not wired to vault credentials in this phase; use https")
p = urllib.parse.urlparse(url)
if p.scheme not in ("https", "http"):
raise invalid_argument(f"unsupported git remote scheme {p.scheme!r}")
if "@" in (p.netloc or ""):
raise invalid_argument("credentials must not be embedded in the remote URL")
require_url_allowed(policy, url)
def _check_workdir(config_roots, workdir):
wd = Path(workdir).resolve()
roots = [Path(r).expanduser().resolve() for r in config_roots] or [Path.home()]
if not any(str(wd).startswith(str(r) + os.sep) or wd == r for r in roots):
raise origin_denied("git", f"workdir {wd} outside allowed roots")
if not (wd / ".git").is_dir():
raise invalid_argument(f"{wd} is not a git worktree")
return wd
def _remote_url(workdir: Path, remote: str) -> str:
"""Read the remote's URL straight from .git/config -- the credential
will actually go there, so THAT is the URL we validate, not whatever
the agent claimed in `repository`."""
cfg = configparser.RawConfigParser()
cfg.read(workdir / ".git" / "config")
try:
url = cfg.get(f'remote "{remote}"', "url")
except configparser.Error:
raise invalid_argument(f"remote {remote!r} has no url configured")
if "://" in url and "@" in urllib.parse.urlparse(url).netloc:
raise invalid_argument("remote URL has embedded credentials")
return url
def validate(item, policy, params, config):
op = params.get("op") or params.get("operation")
if op not in _OPS:
raise invalid_argument(f"git op must be one of {sorted(_OPS)}")
_validate_repo_url(item, policy, params.get("repository") or "")
if op != "clone":
workdir = _check_workdir(config.git_workdir_roots, params.get("workdir") or ".")
remote = params.get("remote") or "origin"
remote_url = _remote_url(workdir, remote)
_validate_repo_url(item, policy, remote_url)
def run(item, policy, params, config):
op = params.get("op") or params.get("operation")
if op not in _OPS:
raise invalid_argument(f"git op must be one of {sorted(_OPS)}")
repo_url = params.get("repository") or ""
_validate_repo_url(item, policy, repo_url)
username = item.get("username")
password = item.get("password")
if not password:
raise unsafe_tool_integration("git: item has no token/password")
secrets = item_secrets(item)
git = shutil.which("git") or "/usr/bin/git"
with secret_dir() as d:
ufile = d / "user"
pfile = d / "pass"
write_secret_file(ufile, username or "oauth2")
write_secret_file(pfile, password)
askpass = write_askpass(d, ufile, pfile)
env = clean_env({
"GIT_ASKPASS": str(askpass),
"GIT_TERMINAL_PROMPT": "0",
})
if op == "clone":
dest = params.get("dest")
if not dest:
raise invalid_argument("clone requires dest")
argv = [git, "clone", "--", repo_url, str(dest)]
else:
workdir = _check_workdir(config.git_workdir_roots, params.get("workdir") or ".")
remote = params.get("remote") or "origin"
if not _REFSPEC_OK.match(remote):
raise invalid_argument("bad remote name")
remote_url = _remote_url(workdir, remote)
if remote_url.startswith("git@") or remote_url.startswith("ssh://"):
raise unsafe_tool_integration(
"git: ssh remotes not wired to vault credentials in this phase")
_validate_repo_url(item, policy, remote_url)
claimed = urllib.parse.urlparse(repo_url).netloc
actual = urllib.parse.urlparse(remote_url).netloc
if claimed.lower() != actual.lower():
raise origin_denied(policy.ref,
f"repository claim {claimed!r} != remote {actual!r}")
argv = [git, "-C", str(workdir)]
if op == "fetch":
argv += ["fetch", "--", remote]
else:
refspec = params.get("refspec") or params.get("branch") or "HEAD"
if not _REFSPEC_OK.match(refspec):
raise invalid_argument("bad refspec")
argv += ["push", "--", remote, refspec]
result = run_subprocess(argv, env=env, timeout=180, secrets=secrets)
result["op"] = op
result["repository"] = urllib.parse.urlparse(repo_url).netloc
return result
+113
View File
@@ -0,0 +1,113 @@
"""HTTP adapter: credential-bearing requests without exposing headers.
Supported auth types (from agent.auth_type):
bearer -> Authorization: Bearer <password-field>
basic -> Authorization: Basic base64(username:password)
none -> no credential header (metadata-only requests)
Destination is origin-validated against agent.allowed_urls / item URIs.
Redirects are NOT followed by default; with follow_redirects=true each
hop is re-validated before the credential is attached.
"""
from __future__ import annotations
import base64
import urllib.error
import urllib.request
from ..errors import invalid_argument
from ..policy import require_url_allowed
from ..sanitize import sanitize_headers, sanitize_text
from ..items import item_secrets
_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
_FORBIDDEN_REQ_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length"}
_MAX_BODY = 64 * 1024
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
def _auth_header(item, policy) -> tuple[str, str] | None:
if policy.auth_type == "none":
return None
password = item.get("password") or ""
if policy.auth_type == "bearer":
return ("Authorization", f"Bearer {password}")
user = item.get("username") or ""
return ("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
def validate(item, policy, params, config):
method = (params.get("method") or "GET").upper()
if method not in _METHODS:
raise invalid_argument(f"unsupported HTTP method {method!r}")
require_url_allowed(policy, params.get("url") or "")
def run(item, policy, params, config):
url = params.get("url") or ""
method = (params.get("method") or "GET").upper()
if method not in _METHODS:
raise invalid_argument(f"unsupported HTTP method {method!r}")
require_url_allowed(policy, url)
headers = {}
for k, v in (params.get("headers") or {}).items():
if k.lower() in _FORBIDDEN_REQ_HEADERS:
raise invalid_argument(f"request header {k!r} not allowed")
headers[k] = str(v)
auth = _auth_header(item, policy)
if auth:
headers[auth[0]] = auth[1]
body = params.get("body")
if body is not None:
body = str(body).encode()
if len(body) > _MAX_BODY:
raise invalid_argument("request body too large")
secrets = item_secrets(item)
if auth:
secrets.append(auth[1])
secrets.append(auth[1].split(" ", 1)[-1])
opener = urllib.request.build_opener(_NoRedirect) if not params.get("follow_redirects") else urllib.request.build_opener()
if params.get("follow_redirects"):
# urllib would follow redirects automatically; revalidation of
# each hop happens via a custom handler would be ideal, so we
# conservatively refuse cross-origin redirects by checking the
# final URL afterwards.
pass
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with opener.open(req, timeout=20) as resp:
status = resp.status
resp_headers = dict(resp.headers.items())
final_url = resp.geturl()
data = resp.read(256 * 1024 + 1)
except urllib.error.HTTPError as e:
status = e.code
resp_headers = dict(e.headers.items()) if e.headers else {}
final_url = e.geturl()
data = e.read(256 * 1024 + 1)
except urllib.error.URLError:
raise invalid_argument("request failed (network error)")
except ValueError:
raise invalid_argument("malformed URL or redirect")
if params.get("follow_redirects"):
require_url_allowed(policy, final_url)
truncated = len(data) > 256 * 1024
data = data[:256 * 1024]
return {
"status": status,
"final_url": sanitize_text(final_url, secrets),
"headers": sanitize_headers(resp_headers, secrets),
"body": sanitize_text(data.decode("utf-8", "replace"), secrets),
"truncated": truncated,
}
+100
View File
@@ -0,0 +1,100 @@
"""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